Adds addLineItem to cart service; adds canCoverQuantity to product variant service

This commit is contained in:
Sebastian Rindom
2020-01-30 16:13:03 +01:00
parent a912b81426
commit 8c515fa9d4
9 changed files with 493 additions and 1 deletions

View File

@@ -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)
}),
}

View File

@@ -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)
}),
}

View File

@@ -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: {} },
}
}

View File

@@ -20,6 +20,7 @@ export default new mongoose.Schema({
// [
// {
// unit_price: (MoneyAmount),
// quantity: (Number),
// variant: (ProductVariantSchema),
// product: (ProductSchema)
// }

View File

@@ -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({})

View File

@@ -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`
)
}
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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

View File

@@ -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