From 8c515fa9d41abb6ed5eaef5cac45aa821e39a39d Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Thu, 30 Jan 2020 16:13:03 +0100 Subject: [PATCH] Adds addLineItem to cart service; adds canCoverQuantity to product variant service --- packages/medusa/src/models/__mocks__/cart.js | 31 +++ .../src/models/__mocks__/product-variant.js | 27 +++ packages/medusa/src/models/product-variant.js | 5 +- .../medusa/src/models/schemas/line-item.js | 1 + .../src/services/__mocks__/product-variant.js | 11 + .../medusa/src/services/__tests__/cart.js | 197 ++++++++++++++++++ .../src/services/__tests__/product-variant.js | 46 ++++ packages/medusa/src/services/cart.js | 153 ++++++++++++++ .../medusa/src/services/product-variant.js | 23 ++ 9 files changed, 493 insertions(+), 1 deletion(-) diff --git a/packages/medusa/src/models/__mocks__/cart.js b/packages/medusa/src/models/__mocks__/cart.js index 8622b02acd..c7b5945b9e 100644 --- a/packages/medusa/src/models/__mocks__/cart.js +++ b/packages/medusa/src/models/__mocks__/cart.js @@ -11,6 +11,34 @@ export const carts = { discounts: [], customer_id: "", }, + cartWithLine: { + _id: IdMap.getId("emptyCart"), + title: "test", + region: IdMap.getId("testRegion"), + items: [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + shippingAddress: {}, + billingAddress: {}, + discounts: [], + customer_id: "", + }, } export const CartModelMock = { @@ -23,6 +51,9 @@ export const CartModelMock = { if (query._id === IdMap.getId("emptyCart")) { return Promise.resolve(carts.emptyCart) } + if (query._id === IdMap.getId("cartWithLine")) { + return Promise.resolve(carts.cartWithLine) + } return Promise.resolve(undefined) }), } diff --git a/packages/medusa/src/models/__mocks__/product-variant.js b/packages/medusa/src/models/__mocks__/product-variant.js index 385afbbe57..58685d2834 100644 --- a/packages/medusa/src/models/__mocks__/product-variant.js +++ b/packages/medusa/src/models/__mocks__/product-variant.js @@ -28,6 +28,33 @@ export const ProductVariantModelMock = { if (query._id === IdMap.getId("failId")) { return Promise.reject(new Error("test error")) } + if (query._id === IdMap.getId("inventory-test")) { + return Promise.resolve({ + _id: IdMap.getId("inventory-test"), + title: "inventory", + inventory_quantity: 10, + allow_backorder: false, + manage_inventory: true, + }) + } + if (query._id === IdMap.getId("no-inventory-test")) { + return Promise.resolve({ + _id: IdMap.getId("no-inventory-test"), + title: "inventory", + inventory_quantity: 0, + allow_backorder: false, + manage_inventory: false, + }) + } + if (query._id === IdMap.getId("backorder-test")) { + return Promise.resolve({ + _id: IdMap.getId("backorder-test"), + title: "inventory", + inventory_quantity: 5, + allow_backorder: true, + manage_inventory: true, + }) + } return Promise.resolve(undefined) }), } diff --git a/packages/medusa/src/models/product-variant.js b/packages/medusa/src/models/product-variant.js index a18561bd07..7d42680117 100644 --- a/packages/medusa/src/models/product-variant.js +++ b/packages/medusa/src/models/product-variant.js @@ -15,8 +15,11 @@ class ProductVariantModel extends BaseModel { prices: { type: [MoneyAmountSchema], default: [], required: true }, options: { type: [OptionValueSchema], default: [] }, image: { type: String, default: "" }, - metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, published: { type: Boolean, default: false }, + inventory_quantity: { type: Number, default: 0 }, + allow_backorder: { type: Boolean, default: false }, + manage_inventory: { type: Boolean, default: true }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/models/schemas/line-item.js b/packages/medusa/src/models/schemas/line-item.js index 124426659a..01b2f84c85 100644 --- a/packages/medusa/src/models/schemas/line-item.js +++ b/packages/medusa/src/models/schemas/line-item.js @@ -20,6 +20,7 @@ export default new mongoose.Schema({ // [ // { // unit_price: (MoneyAmount), + // quantity: (Number), // variant: (ProductVariantSchema), // product: (ProductSchema) // } diff --git a/packages/medusa/src/services/__mocks__/product-variant.js b/packages/medusa/src/services/__mocks__/product-variant.js index c53ef7512f..96e4f6a0a3 100644 --- a/packages/medusa/src/services/__mocks__/product-variant.js +++ b/packages/medusa/src/services/__mocks__/product-variant.js @@ -130,6 +130,17 @@ export const ProductVariantServiceMock = { } return Promise.resolve(undefined) }), + canCoverQuantity: jest.fn().mockImplementation((variantId, quantity) => { + if (variantId === IdMap.getId("can-cover")) { + return Promise.resolve(true) + } + + if (variantId === IdMap.getId("cannot-cover")) { + return Promise.resolve(false) + } + + return Promise.reject(new Error("Not found")) + }), delete: jest.fn().mockReturnValue(Promise.resolve()), addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => { return Promise.resolve({}) diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index f3c42f4632..a0dcbb6b69 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1,6 +1,7 @@ import mongoose from "mongoose" import { IdMap } from "medusa-test-utils" import CartService from "../cart" +import { ProductVariantServiceMock } from "../__mocks__/product-variant" import { RegionServiceMock } from "../__mocks__/region" import { CartModelMock, carts } from "../../models/__mocks__/cart" @@ -59,4 +60,200 @@ describe("CartService", () => { } }) }) + + describe("addLineItem", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + productVariantService: ProductVariantServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully creates new line item", async () => { + const lineItem = { + title: "New Line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + } + + await cartService.addLineItem(IdMap.getId("emptyCart"), lineItem) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("emptyCart"), + }, + { + $push: { items: lineItem }, + } + ) + }) + + it("successfully merges existing line item", async () => { + const lineItem = { + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + } + + await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithLine"), + "items._id": IdMap.getId("existingLine"), + }, + { + $set: { "items.$.quantity": 20 }, + } + ) + }) + + it("successfully adds multi-content line", async () => { + const lineItem = { + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: [ + { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + ], + quantity: 10, + } + + await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) + + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithLine"), + }, + { + $push: { items: lineItem }, + } + ) + }) + + it("throws if line item not validated", async () => { + const lineItem = { + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + } + + try { + await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) + } catch (err) { + expect(err.message).toEqual(`"content" is required`) + } + }) + + it("throws if inventory isn't covered", async () => { + const lineItem = { + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + quantity: 1, + content: { + variant: { + _id: IdMap.getId("cannot-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + unit_price: 1234, + }, + } + + try { + await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) + } catch (err) { + expect(err.message).toEqual( + `Inventory doesn't cover the desired quantity` + ) + } + }) + + it("throws if inventory isn't covered multi-line", async () => { + const lineItem = { + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + quantity: 1, + content: [ + { + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + unit_price: 1234, + }, + { + variant: { + _id: IdMap.getId("cannot-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + unit_price: 1234, + }, + ], + } + + try { + await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) + } catch (err) { + expect(err.message).toEqual( + `Inventory doesn't cover the desired quantity` + ) + } + }) + }) }) diff --git a/packages/medusa/src/services/__tests__/product-variant.js b/packages/medusa/src/services/__tests__/product-variant.js index dedb09bf3a..73f5976933 100644 --- a/packages/medusa/src/services/__tests__/product-variant.js +++ b/packages/medusa/src/services/__tests__/product-variant.js @@ -413,4 +413,50 @@ describe("ProductVariantService", () => { }) }) }) + + describe("canCoverQuantity", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns true if there is more inventory than requested", async () => { + const res = await productVariantService.canCoverQuantity( + IdMap.getId("inventory-test"), + 10 + ) + + expect(res).toEqual(true) + }) + + it("returns true if inventory not managed", async () => { + const res = await productVariantService.canCoverQuantity( + IdMap.getId("no-inventory-test"), + 10 + ) + + expect(res).toEqual(true) + }) + + it("returns true if backorders allowed", async () => { + const res = await productVariantService.canCoverQuantity( + IdMap.getId("backorder-test"), + 10 + ) + + expect(res).toEqual(true) + }) + + it("returns false if insufficient inventory", async () => { + const res = await productVariantService.canCoverQuantity( + IdMap.getId("inventory-test"), + 20 + ) + + expect(res).toEqual(false) + }) + }) }) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index 830bef1df4..19355481a7 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -51,6 +51,93 @@ class CartService extends BaseService { return value } + /** + * Used to validate line items. + * @param {object} rawLineItem - the raw cart id to validate. + * @return {object} the validated id + */ + validateLineItem_(rawLineItem) { + const content = Validator.object({ + unit_price: Validator.number().required(), + variant: Validator.object().required(), + product: Validator.object().required(), + quantity: Validator.number() + .integer() + .min(1), + }) + + const lineItemSchema = Validator.object({ + title: Validator.string().required(), + description: Validator.string(), + thumbnail: Validator.string(), + content: Validator.alternatives() + .try(content, Validator.array().items(content)) + .required(), + quantity: Validator.number() + .integer() + .min(1) + .required(), + metadata: Validator.object(), + }) + + const { value, error } = lineItemSchema.validate(rawLineItem) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + error.details[0].message + ) + } + + return value + } + + /** + * Contents of a line item + * @typedef {(object | array)} LineItemContent + * @property {number} unit_price - the price of the content + * @property {object} variant - the product variant of the content + * @property {object} product - the product of the content + * @property {number} quantity - the quantity of the content + */ + + /** + * A collection of contents grouped in the same line item + * @typedef {LineItemContent[]} LineItemContentArray + */ + + /** + * Confirms if the contents of a line item is covered by the inventory. + * To be covered a variant must either not have its inventory managed or it + * must allow backorders or it must have enough inventory to cover the request. + * If the content is made up of multiple variants it will return true if all + * variants can be covered. If the content consists of a single variant it will + * return true if the variant is covered. + * @param {(LineItemContent | LineItemContentArray)} - the content of the line + * item + * @param {number} - the quantity of the line item + * @return {boolean} true if the inventory covers the line item. + */ + async confirmInventory_(content, lineQuantity) { + if (Array.isArray(content)) { + const coverage = await Promise.all( + content.map(({ variant, quantity }) => { + return this.productVariantService_.canCoverQuantity( + variant._id, + lineQuantity * quantity + ) + }) + ) + + return coverage.every(c => c) + } + + const { variant, quantity } = content + return this.productVariantService_.canCoverQuantity( + variant._id, + lineQuantity * quantity + ) + } + /** * @param {Object} selector - the query object for find * @return {Promise} the result of the find operation @@ -84,6 +171,72 @@ class CartService extends BaseService { return decorated } + /** + * + */ + async addLineItem(cartId, lineItem) { + const validatedLineItem = this.validateLineItem_(lineItem) + + const cart = await this.retrieve(cartId) + const currentItem = cart.items.find(line => + _.isEqual(line.content, validatedLineItem.content) + ) + + // If content matches one of the line items currently in the cart we can + // simply update the quantity of the existing line item + if (currentItem) { + const newQuantity = currentItem.quantity + validatedLineItem.quantity + + // Confirm inventory + const hasInventory = await this.confirmInventory_( + validatedLineItem.content, + newQuantity + ) + + if (!hasInventory) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Inventory doesn't cover the desired quantity" + ) + } + + return this.cartModel_.updateOne( + { + _id: cartId, + "items._id": currentItem._id, + }, + { + $set: { + "items.$.quantity": newQuantity, + }, + } + ) + } + + // Confirm inventory + const hasInventory = await this.confirmInventory_( + validatedLineItem.content, + validatedLineItem.quantity + ) + + if (!hasInventory) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Inventory doesn't cover the desired quantity" + ) + } + + // The line we are adding doesn't already exist so it is safe to push + return this.cartModel_.updateOne( + { + _id: cartId, + }, + { + $push: { items: validatedLineItem }, + } + ) + } + /** * Dedicated method to set metadata for a cart. * To ensure that plugins does not overwrite each diff --git a/packages/medusa/src/services/product-variant.js b/packages/medusa/src/services/product-variant.js index fb1dc77f85..92534fba23 100644 --- a/packages/medusa/src/services/product-variant.js +++ b/packages/medusa/src/services/product-variant.js @@ -194,6 +194,29 @@ class ProductVariantService extends BaseService { ) } + /** + * Checks if the inventory of a variant can cover a given quantity. Will + * return true if the variant doesn't have managed inventory or if the variant + * allows backorders or if the inventory quantity is greater than `quantity`. + * @params {string} variantId - the id of the variant to check + * @params {number} quantity - the number of units to check availability for + * @return {boolean} true if the inventory covers the quantity + */ + async canCoverQuantity(variantId, quantity) { + const variant = await this.retrieve(variantId) + if (!variant) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Variant with ${variantId} was not found` + ) + } + + const { inventory_quantity, allow_backorder, manage_inventory } = variant + return ( + !manage_inventory || allow_backorder || inventory_quantity >= quantity + ) + } + /** * @param {Object} selector - the query object for find * @return {Promise} the result of the find operation