Adds addLineItem to cart service; adds canCoverQuantity to product variant service
This commit is contained in:
@@ -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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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: {} },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export default new mongoose.Schema({
|
||||
// [
|
||||
// {
|
||||
// unit_price: (MoneyAmount),
|
||||
// quantity: (Number),
|
||||
// variant: (ProductVariantSchema),
|
||||
// product: (ProductSchema)
|
||||
// }
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user