Completes Cart Service and store/carts endpoints (#18)

Completes Cart Service to allow shopping and checkout flows.
This commit is contained in:
Sebastian Rindom
2020-03-16 09:48:52 +01:00
committed by GitHub
parent 8e7b66a205
commit 51aaf5105c
31 changed files with 2091 additions and 45 deletions

9
docs/api/store/carts.md Normal file
View File

@@ -0,0 +1,9 @@
POST /carts
GET /carts/:id
POST /carts/:id
POST /carts/:id/line-items
POST /carts/:id/line-items/:line_id
GET /carts/:id/shipping-options
POST /carts/:id/payment-sessions

View File

@@ -50,7 +50,7 @@ removes the line item with the given line id by calling `removeLineItem`.
### Initializing the checkout
When the customer is ready to check out they will reach your checkout page. At
this point you want to display which payment and shipping options are offered.
`POST /cart/payment-providers` and `POST /cart/fulfillment-providers` will
`POST /cart/payment-sessions` and `POST /cart/fulfillment-sessions` will
initialize payment sessions and shipping methods offered by fulfillment
providers. The payment sessions will typically have to be updated if any further
changes to the cart total happens (shipping fee, promo codes, user exits
@@ -93,3 +93,89 @@ function `setPaymentMethod` which will fetch the cart, search the
check that the payment session is authorized. When the authorization is verified
the payment method is set and the controller can safely call the Order service
function `create`.
### How do payment sessions work?
When the customer first enters the checkout page you should initialize payment
sessions for each of the possible payment providers. This is done with a single
call to `POST /cart/payment-sessions`. Calls to `POST /cart/payment-sessions`
will either create payment sessions for each payment provider or if the payment
sessions have already been initialized ensure that the sessions are up to date (
i.e. that the cart amount corresponds to the payment sessions' amounts). When
the customer reaches the payment part of the checkout (or alternatively when she
decides to use one of the payment providers as a checkout provider) the payment
method will be saved once authorized.
### How do fulfillment sessions work?
When the customer first enters the checkout page, fulfillment sessions should be
initialized. The fulfillment session is responsible for fetching shipping
options with a fulfillment provider. E.g. your store has an integration with
your 3PL as a fulfillment provider. The 3PL has 4 shipping options: standard,
express and fragile shipping as well as a parcel shop service where orders are
delivered to a local store.
The store operator will have set up which shipping options are available in the
customer's region. I.e. the store operator may have created a shipping option
called Free Shipping, which uses the "Standard Shipping" method from the 3PL
integration, and which is free when the order value is above 100 USD. The store
operator may have also created an Express Shipping option which uses the
"Express Shipping" method from the 3PL integration and which costs 20 USD.
The store operator has also created a Fragile Shipping option which uses the
"Fragile Shipping" method from the 3PL integration and which has variable
pricing depending on the size of the shipment. The variable pricing is
calculated by the integration depending on cart. Finally, the store operator has
defined a parcel shop option, which uses the 3PL's parcel shop shipping method.
The customer needs to provide the ID of the local store that she wants her order
delivered to and the shipping method therefore takes some additional input to be
a valid shipping method for an order.
When the customer enters the checkout page the `POST /cart/shipping-options`
call will fetch each of the shipping options that the store operator has set up.
Extending the above example, an array of shipping options would be stored in the
cart in the format:
```
[
{
_id: [some-id],
provider_id: "3pl_integration",
name: "Free Shipping",
price: 0,
data: {
// This will contain data specific to the shipping method, i.e. the
// id that the 3PL needs in order to process the order with this shipping
// method
}
},
{
_id: [some-id],
provider_id: "3pl_integration",
name: "Express Shipping",
price: 20,
data: {
// This will contain data specific to the shipping method, i.e. the
// id that the 3PL needs in order to process the order with this shipping
// method
}
},
{
_id: [some-id],
provider_id: "3pl_integration",
name: "Fragile Shipping",
price: 120, // Calculated from the cart
data: {
// This will contain data specific to the shipping method, i.e. the
// id that the 3PL needs in order to process the order with this shipping
// method
}
},
...
]
```
If the customer changes her cart, all shipping options will be recalculated. For
example, if the customer removes something from the cart so that they no longer
qualify for free shipping, the free shipping method will be removed at the same
time the fragile shipping method's price will be updated.

View File

@@ -7,6 +7,7 @@ export default () => {
let statusCode = 500
switch (err.name) {
case MedusaError.Types.NOT_ALLOWED:
case MedusaError.Types.INVALID_DATA:
statusCode = 400
break

View File

@@ -0,0 +1,166 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item"
describe("POST /store/carts/:id/shipping-methods", () => {
describe("successfully adds a shipping method", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("fr-cart")
subject = await request(
"POST",
`/store/carts/${cartId}/shipping-methods`,
{
payload: {
option_id: IdMap.getId("freeShipping"),
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService retrieveShippingOption", () => {
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith(
IdMap.getId("fr-cart"),
IdMap.getId("freeShipping")
)
})
it("calls CartService addShipping", () => {
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1)
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith(
IdMap.getId("fr-cart"),
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("fr-cart"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("successfully adds a shipping method with additional data", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("fr-cart")
subject = await request(
"POST",
`/store/carts/${cartId}/shipping-methods`,
{
payload: {
option_id: IdMap.getId("freeShipping"),
data: {
extra_id: "id",
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService retrieveShippingOption", () => {
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith(
IdMap.getId("fr-cart"),
IdMap.getId("freeShipping")
)
})
it("calls CartService addShipping", () => {
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1)
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith(
IdMap.getId("fr-cart"),
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
data: {
extra_id: "id",
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("fr-cart"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("additional data without overwriting", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("emptyCart")
subject = await request(
"POST",
`/store/carts/${cartId}/shipping-methods`,
{
payload: {
option_id: IdMap.getId("withData"),
data: {
extra_id: "id",
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService retrieveShippingOption", () => {
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith(
IdMap.getId("emptyCart"),
IdMap.getId("withData")
)
})
it("calls CartService addShipping", () => {
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1)
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith(
IdMap.getId("emptyCart"),
{
_id: IdMap.getId("withData"),
profile_id: "default_profile",
data: {
extra_id: "id",
some_data: "yes",
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})
})

View File

@@ -26,12 +26,13 @@ describe("POST /store/carts", () => {
})
})
it("returns 201", () => {
expect(subject.status).toEqual(201)
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("regionCart"))
expect(subject.body.decorated).toEqual(true)
})
})
@@ -97,8 +98,8 @@ describe("POST /store/carts", () => {
jest.clearAllMocks()
})
it("returns 201", () => {
expect(subject.status).toEqual(201)
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls line item generate", () => {
@@ -117,6 +118,7 @@ describe("POST /store/carts", () => {
it("returns cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("regionCart"))
expect(subject.body.decorated).toEqual(true)
})
})

View File

@@ -0,0 +1,87 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item"
describe("POST /store/carts/:id", () => {
describe("successfully creates a line item", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("emptyCart")}/line-items`,
{
payload: {
variant_id: IdMap.getId("testVariant"),
quantity: 3,
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService create", () => {
expect(CartServiceMock.addLineItem).toHaveBeenCalledTimes(1)
})
it("calls LineItemService generate", () => {
expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1)
expect(LineItemServiceMock.generate).toHaveBeenCalledWith(
IdMap.getId("testVariant"),
3,
IdMap.getId("testRegion")
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("handles unsuccessful line item generation", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("emptyCart")}/line-items`,
{
payload: {
variant_id: IdMap.getId("fail"),
quantity: 3,
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls LineItemService generate", () => {
expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1)
expect(LineItemServiceMock.generate).toHaveBeenCalledWith(
IdMap.getId("fail"),
3,
IdMap.getId("testRegion")
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message).toEqual("Doesn't exist")
})
})
})

View File

@@ -0,0 +1,33 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
describe("POST /store/carts/:id/payment-sessions", () => {
describe("creates payment sessions", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("emptyCart")}/payment-sessions`
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls Cart service set payment sessions", () => {
expect(CartServiceMock.setPaymentSessions).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})
})

View File

@@ -0,0 +1,33 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
describe("POST /store/carts/:id/shipping-options", () => {
describe("creates shipping options", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("emptyCart")}/shipping-options`
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls Cart service set shipping options", () => {
expect(CartServiceMock.setShippingOptions).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})
})

View File

@@ -23,6 +23,7 @@ describe("GET /store/carts", () => {
it("returns products", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})

View File

@@ -86,6 +86,7 @@ describe("POST /store/carts/:id", () => {
it("returns cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})

View File

@@ -0,0 +1,92 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item"
describe("POST /store/carts/:id/line-items/:line_id", () => {
describe("successfully updates a line item", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("emptyCart")
const lineId = IdMap.getId("existingLine")
subject = await request(
"POST",
`/store/carts/${cartId}/line-items/${lineId}`,
{
payload: {
variant_id: IdMap.getId("can-cover"),
quantity: 3,
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService create", () => {
expect(CartServiceMock.updateLineItem).toHaveBeenCalledTimes(1)
})
it("calls LineItemService generate", () => {
expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1)
expect(LineItemServiceMock.generate).toHaveBeenCalledWith(
IdMap.getId("can-cover"),
3,
IdMap.getId("testRegion")
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("handles unsuccessful line item generation", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("emptyCart")
const lineId = IdMap.getId("existingLine")
subject = await request(
"POST",
`/store/carts/${cartId}/line-items/${lineId}`,
{
payload: {
variant_id: IdMap.getId("fail"),
quantity: 3,
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls LineItemService generate", () => {
expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1)
expect(LineItemServiceMock.generate).toHaveBeenCalledWith(
IdMap.getId("fail"),
3,
IdMap.getId("testRegion")
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message).toEqual("Doesn't exist")
})
})
})

View File

@@ -0,0 +1,99 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item"
describe("POST /store/carts/:id/payment-method", () => {
describe("successfully sets the payment method", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("cartWithPaySessions")
subject = await request("POST", `/store/carts/${cartId}/payment-method`, {
payload: {
provider_id: "default_provider",
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService retrievePaymentSession", () => {
expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledWith(
IdMap.getId("cartWithPaySessions"),
"default_provider"
)
})
it("calls CartService setPaymentMethod", () => {
expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledTimes(1)
expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledWith(
IdMap.getId("cartWithPaySessions"),
{
provider_id: "default_provider",
data: {
money_id: "success",
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the cart", () => {
expect(subject.body._id).toEqual(IdMap.getId("cartWithPaySessions"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("fails when pay session not authorized", () => {
let subject
beforeAll(async () => {
const cartId = IdMap.getId("cartWithPaySessions")
subject = await request("POST", `/store/carts/${cartId}/payment-method`, {
payload: {
provider_id: "nono",
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls CartService retrievePaymentSession", () => {
expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledWith(
IdMap.getId("cartWithPaySessions"),
"nono"
)
})
it("calls CartService setPaymentMethod", () => {
expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledTimes(1)
expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledWith(
IdMap.getId("cartWithPaySessions"),
{
provider_id: "nono",
data: {
money_id: "fail",
},
}
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns the cart", () => {
expect(subject.body.message).toEqual("Not allowed")
})
})
})

View File

@@ -0,0 +1,39 @@
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
option_id: Validator.string().required(),
data: Validator.object().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const cartService = req.scope.resolve("cartService")
const method = await cartService.retrieveShippingOption(id, value.option_id)
// If the option accepts additional data this will be added
if (!_.isEmpty(value.data)) {
method.data = {
...method.data,
...value.data,
}
}
await cartService.addShippingMethod(id, method)
let cart = await cartService.retrieve(id)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}
}

View File

@@ -35,7 +35,8 @@ export default async (req, res) => {
}
cart = await cartService.retrieve(cart._id)
res.status(201).json(cart)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}

View File

@@ -0,0 +1,35 @@
import { Validator, MedusaError } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
variant_id: Validator.string().required(),
quantity: Validator.number().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const lineItemService = req.scope.resolve("lineItemService")
const cartService = req.scope.resolve("cartService")
let cart = await cartService.retrieve(id)
const lineItem = await lineItemService.generate(
value.variant_id,
value.quantity,
cart.region_id
)
await cartService.addLineItem(cart._id, lineItem)
cart = await cartService.retrieve(cart._id)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,18 @@
export default async (req, res) => {
const { id } = req.params
try {
const cartService = req.scope.resolve("cartService")
// Ask the cart service to set payment sessions
await cartService.setPaymentSessions(id)
// return the updated cart
let cart = await cartService.retrieve(id)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,17 @@
export default async (req, res) => {
const { id } = req.params
try {
const cartService = req.scope.resolve("cartService")
// Ask the cart service to set payment sessions
await cartService.setShippingOptions(id)
// return the updated cart
let cart = await cartService.retrieve(id)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}
}

View File

@@ -2,12 +2,14 @@ export default async (req, res) => {
const { id } = req.params
const cartService = req.scope.resolve("cartService")
const cart = await cartService.retrieve(id)
let cart = await cartService.retrieve(id)
if (!cart) {
res.sendStatus(404)
return
}
cart = await cartService.decorate(cart)
res.json(cart)
}

View File

@@ -11,5 +11,35 @@ export default app => {
route.post("/", middlewares.wrap(require("./create-cart").default))
route.post("/:id", middlewares.wrap(require("./update-cart").default))
// Line items
route.post(
"/:id/line-items",
middlewares.wrap(require("./create-line-item").default)
)
route.post(
"/:id/line-items/:line_id",
middlewares.wrap(require("./update-line-item").default)
)
// Payment sessions
route.post(
"/:id/payment-sessions",
middlewares.wrap(require("./create-payment-sessions").default)
)
route.post(
"/:id/payment-method",
middlewares.wrap(require("./update-payment-method").default)
)
// Shipping Options
route.post(
"/:id/shipping-options",
middlewares.wrap(require("./create-shipping-options").default)
)
route.post(
"/:id/shipping-methods",
middlewares.wrap(require("./add-shipping-method").default)
)
return app
}

View File

@@ -51,7 +51,8 @@ export default async (req, res) => {
)
}
const newCart = await cartService.retrieve(id)
let newCart = await cartService.retrieve(id)
newCart = await cartService.decorate(newCart)
res.json(newCart)
} catch (err) {
throw err

View File

@@ -0,0 +1,36 @@
import { Validator, MedusaError } from "medusa-core-utils"
export default async (req, res) => {
const { id, line_id } = req.params
const schema = Validator.object().keys({
variant_id: Validator.objectId().required(),
quantity: Validator.number().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const lineItemService = req.scope.resolve("lineItemService")
const cartService = req.scope.resolve("cartService")
let cart = await cartService.retrieve(id)
const lineItem = await lineItemService.generate(
value.variant_id,
value.quantity,
cart.region_id
)
await cartService.updateLineItem(cart._id, line_id, lineItem)
cart = await cartService.retrieve(cart._id)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,31 @@
import { Validator, MedusaError } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
provider_id: Validator.string().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const cartService = req.scope.resolve("cartService")
const session = await cartService.retrievePaymentSession(
id,
value.provider_id
)
await cartService.setPaymentMethod(id, session)
let cart = await cartService.retrieve(id)
cart = await cartService.decorate(cart)
res.status(200).json(cart)
} catch (err) {
throw err
}
}

View File

@@ -6,8 +6,117 @@ export const carts = {
title: "test",
region_id: IdMap.getId("testRegion"),
items: [],
shippingAddress: {},
billingAddress: {},
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
withShippingOptions: {
_id: IdMap.getId("withShippingOptions"),
title: "test",
region_id: IdMap.getId("region-france"),
items: [],
shipping_options: [
{
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
region_id: IdMap.getId("testRegion"),
price: 10,
provider_id: "test_shipper",
},
{
_id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
region_id: IdMap.getId("testRegion"),
price: 100,
provider_id: "test_shipper",
},
],
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
cartWithPaySessionsDifRegion: {
_id: IdMap.getId("cartWithPaySessionsDifRegion"),
region_id: IdMap.getId("region-france"),
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,
},
],
payment_sessions: [
{
provider_id: "default_provider",
data: {
id: "default_provider_session",
},
},
{
provider_id: "unregistered",
data: {
id: "unregistered_session",
},
},
],
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
cartWithPaySessions: {
_id: IdMap.getId("cartWithPaySessions"),
region_id: IdMap.getId("testRegion"),
shipping_methods: [],
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,
},
],
payment_sessions: [
{
provider_id: "default_provider",
data: {
id: "default_provider_session",
},
},
{
provider_id: "unregistered",
data: {
id: "unregistered_session",
},
},
],
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
@@ -34,8 +143,8 @@ export const carts = {
quantity: 10,
},
],
shippingAddress: {},
billingAddress: {},
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
@@ -50,12 +159,14 @@ export const carts = {
yes: "sir",
},
},
shipping_method: {
provider_id: "gls",
data: {
yes: "sir",
shipping_methods: [
{
provider_id: "gls",
data: {
yes: "sir",
},
},
},
],
shipping_address: {
first_name: "hi",
last_name: "you",
@@ -127,8 +238,20 @@ export const carts = {
quantity: 10,
},
],
shippingAddress: {},
billingAddress: {},
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
shipping_options: [
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
@@ -141,6 +264,15 @@ export const CartModelMock = {
}),
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
findOne: jest.fn().mockImplementation(query => {
if (query._id === IdMap.getId("withShippingOptions")) {
return Promise.resolve(carts.withShippingOptions)
}
if (query._id === IdMap.getId("cartWithPaySessionsDifRegion")) {
return Promise.resolve(carts.cartWithPaySessionsDifRegion)
}
if (query._id === IdMap.getId("cartWithPaySessions")) {
return Promise.resolve(carts.cartWithPaySessions)
}
if (query._id === IdMap.getId("emptyCart")) {
return Promise.resolve(carts.emptyCart)
}

View File

@@ -20,6 +20,8 @@ class CartModel extends BaseModel {
region_id: { type: String, required: true },
discounts: { type: [String], default: [] },
customer_id: { type: String, default: "" },
payment_sessions: { type: [PaymentMethodSchema], default: [] },
shipping_options: { type: [ShippingMethodSchema], default: [] },
payment_method: { type: PaymentMethodSchema, default: {} },
shipping_methods: { type: [ShippingMethodSchema], default: [] },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },

View File

@@ -5,12 +5,132 @@ export const carts = {
emptyCart: {
_id: IdMap.getId("emptyCart"),
items: [],
region_id: IdMap.getId("testRegion"),
shipping_options: [
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
data: {
some_data: "yes",
},
},
],
},
regionCart: {
_id: IdMap.getId("regionCart"),
name: "Product 1",
region_id: IdMap.getId("testRegion"),
},
frCart: {
_id: IdMap.getId("fr-cart"),
title: "test",
region_id: IdMap.getId("region-france"),
items: [
{
_id: IdMap.getId("line"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: [
{
unit_price: 8,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
{
unit_price: 10,
variant: {
_id: IdMap.getId("eur-10-us-12"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
],
quantity: 10,
},
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 10,
variant: {
_id: IdMap.getId("eur-10-us-12"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
],
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
shipping_options: [
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
cartWithPaySessions: {
_id: IdMap.getId("cartWithPaySessions"),
region_id: 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,
},
],
payment_sessions: [
{
provider_id: "default_provider",
data: {
id: "default_provider_session",
},
},
{
provider_id: "unregistered",
data: {
id: "unregistered_session",
},
},
],
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: "",
},
}
export const CartServiceMock = {
@@ -23,17 +143,33 @@ export const CartServiceMock = {
}
}),
retrieve: jest.fn().mockImplementation(cartId => {
if (cartId === IdMap.getId("fr-cart")) {
return Promise.resolve(carts.frCart)
}
if (cartId === IdMap.getId("regionCart")) {
return Promise.resolve(carts.regionCart)
}
if (cartId === IdMap.getId("emptyCart")) {
return Promise.resolve(carts.emptyCart)
}
if (cartId === IdMap.getId("cartWithPaySessions")) {
return Promise.resolve(carts.cartWithPaySessions)
}
return Promise.resolve(undefined)
}),
addLineItem: jest.fn().mockImplementation((cartId, lineItem) => {
return Promise.resolve()
}),
setPaymentMethod: jest.fn().mockImplementation((cartId, method) => {
if (method.provider_id === "default_provider") {
return Promise.resolve()
}
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not allowed")
}),
updateLineItem: jest.fn().mockImplementation((cartId, lineItem) => {
return Promise.resolve()
}),
setRegion: jest.fn().mockImplementation((cartId, regionId) => {
if (regionId === IdMap.getId("fail")) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Region not found")
@@ -52,6 +188,55 @@ export const CartServiceMock = {
applyPromoCode: jest.fn().mockImplementation((cartId, code) => {
return Promise.resolve()
}),
setPaymentSessions: jest.fn().mockImplementation(cartId => {
return Promise.resolve()
}),
setShippingOptions: jest.fn().mockImplementation(cartId => {
return Promise.resolve()
}),
decorate: jest.fn().mockImplementation(cart => {
cart.decorated = true
return cart
}),
addShippingMethod: jest.fn().mockImplementation(cartId => {
return Promise.resolve()
}),
retrieveShippingOption: jest.fn().mockImplementation((cartId, optionId) => {
if (optionId === IdMap.getId("freeShipping")) {
return {
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
}
}
if (optionId === IdMap.getId("withData")) {
return {
_id: IdMap.getId("withData"),
profile_id: "default_profile",
data: {
some_data: "yes",
},
}
}
}),
retrievePaymentSession: jest.fn().mockImplementation((cartId, providerId) => {
if (providerId === "default_provider") {
return {
provider_id: "default_provider",
data: {
money_id: "success",
},
}
}
if (providerId === "nono") {
return {
provider_id: "nono",
data: {
money_id: "fail",
},
}
}
}),
}
const mock = jest.fn().mockImplementation(() => {

View File

@@ -1,4 +1,5 @@
import { IdMap } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
export const LineItemServiceMock = {
validate: jest.fn().mockImplementation(data => {
@@ -8,6 +9,10 @@ export const LineItemServiceMock = {
return data
}),
generate: jest.fn().mockImplementation((variantId, quantity, regionId) => {
if (variantId === IdMap.getId("fail") || regionId === IdMap.getId("fail")) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "Doesn't exist")
}
return Promise.resolve({
content: {
variant: {

View File

@@ -13,6 +13,24 @@ export const DefaultProviderMock = {
}
export const PaymentProviderServiceMock = {
updateSession: jest.fn().mockImplementation((session, cart) => {
return Promise.resolve({
provider_id: session.provider_id,
data: {
...session.data,
id: `${session.data.id}_updated`,
},
})
}),
createSession: jest.fn().mockImplementation((providerId, cart) => {
return Promise.resolve({
provider_id: providerId,
data: {
id: `${providerId}_session`,
cartId: cart._id,
},
})
}),
retrieveProvider: jest.fn().mockImplementation(providerId => {
if (providerId === "default_provider") {
return DefaultProviderMock

View File

@@ -14,6 +14,7 @@ export const regions = {
_id: IdMap.getId("region-france"),
name: "France",
countries: ["FR"],
payment_providers: ["default_provider", "france-provider"],
currency_code: "eur",
},
regionUs: {

View File

@@ -0,0 +1,99 @@
import { IdMap } from "medusa-test-utils"
export const shippingOptions = {
freeShipping: {
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
region_id: IdMap.getId("testRegion"),
profile_id: IdMap.getId("default-profile"),
data: {
id: "fs",
},
price: {
type: "flat_rate",
amount: 10,
},
provider_id: "test_shipper",
},
expensiveShipping: {
_id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
profile_id: IdMap.getId("fragile-profile"),
region_id: IdMap.getId("testRegion"),
data: {
id: "es",
},
price: {
type: "flat_rate",
amount: 100,
},
provider_id: "test_shipper",
},
franceShipping: {
_id: IdMap.getId("franceShipping"),
name: "FR Shipping",
profile_id: IdMap.getId("default-profile"),
region_id: IdMap.getId("region-france"),
data: {
id: "bonjour",
},
price: {
type: "flat_rate",
amount: 20,
},
provider_id: "test_shipper",
},
}
export const ShippingOptionServiceMock = {
retrieve: jest.fn().mockImplementation(optionId => {
if (optionId === IdMap.getId("freeShipping")) {
return Promise.resolve(shippingOptions.freeShipping)
}
return Promise.resolve(undefined)
}),
list: jest.fn().mockImplementation(data => {
if (data.region_id === IdMap.getId("region-france")) {
return Promise.resolve([shippingOptions.franceShipping])
}
if (data.region_id === IdMap.getId("testRegion")) {
return Promise.resolve([
shippingOptions.freeShipping,
shippingOptions.expensiveShipping,
])
}
}),
validateCartOption: jest.fn().mockImplementation((method, cart) => {
if (method._id === IdMap.getId("freeShipping")) {
return Promise.resolve(true)
}
if (method._id === IdMap.getId("franceShipping")) {
return Promise.resolve(true)
}
if (method._id === IdMap.getId("fail")) {
return Promise.resolve(false)
}
}),
fetchCartOptions: jest.fn().mockImplementation(cart => {
if (cart._id === IdMap.getId("cartWithLine")) {
return Promise.resolve([
{
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
region_id: IdMap.getId("testRegion"),
price: 10,
data: {
id: "fs",
},
provider_id: "test_shipper",
},
])
}
}),
}
const mock = jest.fn().mockImplementation(() => {
return ShippingOptionServiceMock
})
export default mock

View File

@@ -7,6 +7,7 @@ import {
} from "../__mocks__/payment-provider"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { RegionServiceMock } from "../__mocks__/region"
import { ShippingOptionServiceMock } from "../__mocks__/shipping-option"
import { CartModelMock, carts } from "../../models/__mocks__/cart"
import { LineItemServiceMock } from "../__mocks__/line-item"
@@ -204,20 +205,6 @@ describe("CartService", () => {
)
})
it("throws if line item not validated", async () => {
const lineItem = {
title: "invalid lineitem",
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",
@@ -285,6 +272,85 @@ describe("CartService", () => {
})
})
describe("updateLineItem", () => {
const cartService = new CartService({
cartModel: CartModelMock,
productVariantService: ProductVariantServiceMock,
lineItemService: LineItemServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("successfully updates existing line item", async () => {
const lineItem = {
title: "update line",
description: "This is a new line",
thumbnail: "https://test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 2,
}
await cartService.updateLineItem(
IdMap.getId("cartWithLine"),
IdMap.getId("existingLine"),
lineItem
)
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithLine"),
"items._id": IdMap.getId("existingLine"),
},
{
$set: { "items.$": lineItem },
}
)
})
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.updateLineItem(
IdMap.getId("cartWithLine"),
IdMap.getId("existingLine"),
lineItem
)
} catch (err) {
expect(err.message).toEqual(
`Inventory doesn't cover the desired quantity`
)
}
})
})
describe("updateEmail", () => {
const cartService = new CartService({
cartModel: CartModelMock,
@@ -459,6 +525,7 @@ describe("CartService", () => {
{
$set: {
region_id: IdMap.getId("region-us"),
shipping_methods: [],
items: [
{
_id: IdMap.getId("line"),
@@ -526,7 +593,7 @@ describe("CartService", () => {
{
$set: {
region_id: IdMap.getId("region-us"),
shipping_method: undefined,
shipping_methods: [],
payment_method: undefined,
shipping_address: {
first_name: "hi",
@@ -711,4 +778,491 @@ describe("CartService", () => {
}
})
})
describe("setPaymentSessions", () => {
const cartService = new CartService({
cartModel: CartModelMock,
regionService: RegionServiceMock,
paymentProviderService: PaymentProviderServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("initializes payment sessions for each of the providers", async () => {
await cartService.setPaymentSessions(IdMap.getId("cartWithLine"))
expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledTimes(2)
expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledWith(
"default_provider",
carts.cartWithLine
)
expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledWith(
"unregistered",
carts.cartWithLine
)
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithLine"),
},
{
$set: {
payment_sessions: [
{
provider_id: "default_provider",
data: {
id: "default_provider_session",
cartId: IdMap.getId("cartWithLine"),
},
},
{
provider_id: "unregistered",
data: {
id: "unregistered_session",
cartId: IdMap.getId("cartWithLine"),
},
},
],
},
}
)
})
it("updates payment sessions for existing sessions", async () => {
await cartService.setPaymentSessions(IdMap.getId("cartWithPaySessions"))
expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledTimes(0)
expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledTimes(2)
expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledWith(
{
provider_id: "default_provider",
data: {
id: "default_provider_session",
},
},
carts.cartWithPaySessions
)
expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledWith(
{
provider_id: "unregistered",
data: {
id: "unregistered_session",
},
},
carts.cartWithPaySessions
)
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithPaySessions"),
},
{
$set: {
payment_sessions: [
{
provider_id: "default_provider",
data: {
id: "default_provider_session_updated",
},
},
{
provider_id: "unregistered",
data: {
id: "unregistered_session_updated",
},
},
],
},
}
)
})
it("filters sessions not available in the region", async () => {
await cartService.setPaymentSessions(
IdMap.getId("cartWithPaySessionsDifRegion")
)
expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledTimes(1)
expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledTimes(1)
expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledWith(
{
provider_id: "default_provider",
data: {
id: "default_provider_session",
},
},
carts.cartWithPaySessionsDifRegion
)
expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledWith(
"france-provider",
carts.cartWithPaySessionsDifRegion
)
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithPaySessionsDifRegion"),
},
{
$set: {
payment_sessions: [
{
provider_id: "default_provider",
data: {
id: "default_provider_session_updated",
},
},
{
provider_id: "france-provider",
data: {
id: "france-provider_session",
cartId: IdMap.getId("cartWithPaySessionsDifRegion"),
},
},
],
},
}
)
})
})
describe("setShippingOptions", () => {
const cartService = new CartService({
cartModel: CartModelMock,
regionService: RegionServiceMock,
shippingOptionService: ShippingOptionServiceMock,
})
describe("gets shipping options from the cart's regions", () => {
beforeAll(async () => {
jest.clearAllMocks()
await cartService.setShippingOptions(IdMap.getId("cartWithLine"))
})
it("gets shipping options from region", () => {
expect(
ShippingOptionServiceMock.fetchCartOptions
).toHaveBeenCalledTimes(1)
expect(ShippingOptionServiceMock.fetchCartOptions).toHaveBeenCalledWith(
carts.cartWithLine
)
})
it("updates cart", () => {
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithLine"),
},
{
$set: {
shipping_options: [
{
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
region_id: IdMap.getId("testRegion"),
price: 10,
data: {
id: "fs",
},
provider_id: "test_shipper",
},
],
},
}
)
})
})
})
describe("retrievePaymentSession", () => {
const cartService = new CartService({
cartModel: CartModelMock,
})
let res
describe("it retrieves the correct payment session", () => {
beforeAll(async () => {
jest.clearAllMocks()
res = await cartService.retrievePaymentSession(
IdMap.getId("cartWithPaySessions"),
"default_provider"
)
})
it("retrieves the cart", () => {
expect(CartModelMock.findOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("cartWithPaySessions"),
})
})
it("finds the correct payment session", () => {
expect(res.provider_id).toEqual("default_provider")
expect(res.data).toEqual({
id: "default_provider_session",
})
})
})
describe("it fails when provider doesn't match open session", () => {
beforeAll(async () => {
jest.clearAllMocks()
try {
await cartService.retrievePaymentSession(
IdMap.getId("cartWithPaySessions"),
"nono"
)
} catch (err) {
res = err
}
})
it("retrieves the cart", () => {
expect(CartModelMock.findOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("cartWithPaySessions"),
})
})
it("throws invalid data errro", () => {
expect(res.message).toEqual(
"The provider_id did not match any open payment sessions"
)
})
})
})
describe("addShippingMethod", () => {
const cartService = new CartService({
cartModel: CartModelMock,
shippingOptionService: ShippingOptionServiceMock,
})
describe("successfully adds the shipping method", () => {
const method = {
_id: IdMap.getId("freeShipping"),
provider_id: "test_shipper",
profile_id: "default_profile",
price: 20,
region_id: IdMap.getId("testRegion"),
data: {
id: "testshipperid",
},
products: [IdMap.getId("product")],
}
beforeAll(async () => {
jest.clearAllMocks()
const cartId = IdMap.getId("cartWithPaySessions")
await cartService.addShippingMethod(cartId, method)
})
it("checks availability", () => {
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledTimes(1)
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledWith(method, carts.cartWithPaySessions)
})
it("updates cart", () => {
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithPaySessions"),
},
{
$set: { shipping_methods: [method] },
}
)
})
})
describe("successfully overrides existing profile shipping method", () => {
const method = {
_id: IdMap.getId("freeShipping"),
provider_id: "test_shipper",
profile_id: "default_profile",
price: 20,
region_id: IdMap.getId("testRegion"),
data: {
id: "testshipperid",
},
products: [IdMap.getId("product")],
}
beforeAll(async () => {
jest.clearAllMocks()
const cartId = IdMap.getId("fr-cart")
await cartService.addShippingMethod(cartId, method)
})
it("checks availability", () => {
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledTimes(1)
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledWith(method, carts.frCart)
})
it("updates cart", () => {
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("fr-cart"),
},
{
$set: { shipping_methods: [method] },
}
)
})
})
describe("successfully adds additional shipping method", () => {
const method = {
_id: IdMap.getId("freeShipping"),
provider_id: "test_shipper",
profile_id: "additional_profile",
price: 20,
region_id: IdMap.getId("testRegion"),
data: {
id: "testshipperid",
},
products: [IdMap.getId("product")],
}
beforeAll(async () => {
jest.clearAllMocks()
const cartId = IdMap.getId("fr-cart")
await cartService.addShippingMethod(cartId, method)
})
it("checks availability", () => {
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledTimes(1)
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledWith(method, carts.frCart)
})
it("updates cart", () => {
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("fr-cart"),
},
{
$set: {
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
method,
],
},
}
)
})
})
describe("throws error on no availability", () => {
const method = {
_id: IdMap.getId("fail"),
}
let res
beforeAll(async () => {
jest.clearAllMocks()
const cartId = IdMap.getId("fr-cart")
try {
await cartService.addShippingMethod(cartId, method)
} catch (err) {
res = err
}
})
it("checks availability", () => {
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledTimes(1)
expect(
ShippingOptionServiceMock.validateCartOption
).toHaveBeenCalledWith(method, carts.frCart)
})
it("throw error", () => {
expect(res.message).toEqual(
"The selected shipping method cannot be applied to the cart"
)
})
})
})
describe("retrieveShippingOption", () => {
const cartService = new CartService({
cartModel: CartModelMock,
})
let res
describe("it retrieves the correct payment session", () => {
beforeAll(async () => {
jest.clearAllMocks()
res = await cartService.retrieveShippingOption(
IdMap.getId("fr-cart"),
IdMap.getId("freeShipping")
)
})
it("retrieves the cart", () => {
expect(CartModelMock.findOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("fr-cart"),
})
})
it("finds the correct payment session", () => {
expect(res._id).toEqual(IdMap.getId("freeShipping"))
})
})
describe("it fails when provider doesn't match open session", () => {
beforeAll(async () => {
jest.clearAllMocks()
try {
await cartService.retrieveShippingOption(
IdMap.getId("fr-cart"),
"nono"
)
} catch (err) {
res = err
}
})
it("retrieves the cart", () => {
expect(CartModelMock.findOne).toHaveBeenCalledTimes(1)
expect(CartModelMock.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("fr-cart"),
})
})
it("throws invalid data errro", () => {
expect(res.message).toEqual(
"The option id doesn't match any available shipping options"
)
})
})
})
})

View File

@@ -15,6 +15,7 @@ class CartService extends BaseService {
productVariantService,
regionService,
lineItemService,
shippingOptionService,
}) {
super()
@@ -38,6 +39,9 @@ class CartService extends BaseService {
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {ShippingOptionsService} */
this.shippingOptionService_ = shippingOptionService
}
/**
@@ -200,9 +204,7 @@ class CartService extends BaseService {
* @return {Cart} return the decorated cart.
*/
async decorate(cart, fields, expandFields = []) {
const requiredFields = ["_id", "metadata"]
const decorated = _.pick(cart, fields.concat(requiredFields))
return decorated
return cart
}
/**
@@ -213,9 +215,7 @@ class CartService extends BaseService {
*/
async addLineItem(cartId, lineItem) {
const validatedLineItem = this.lineItemService_.validate(lineItem)
const cart = await this.retrieve(cartId)
const currentItem = cart.items.find(line =>
_.isEqual(line.content, validatedLineItem.content)
)
@@ -275,6 +275,61 @@ class CartService extends BaseService {
)
}
/**
* Updates a cart's existing line item.
* @param {string} cartId - the id of the cart to update
* @param {string} lineItemId - the id of the line item to update.
* @param {LineItem} lineItem - the line item to update. Must include an _id
* field.
* @return {Promise} the result of the update operation
*/
async updateLineItem(cartId, lineItemId, lineItem) {
const cart = await this.retrieve(cartId)
const validatedLineItem = this.lineItemService_.validate(lineItem)
if (!lineItemId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Line Item must have an _id corresponding to an existing line item id"
)
}
// Ensure that the line item exists in the cart
const lineItemExists = cart.items.find(i => i._id === lineItemId)
if (!lineItemExists) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"A line item with the provided id doesn't exist in the cart"
)
}
// Ensure that inventory covers the request
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"
)
}
// Update the line item
return this.cartModel_.updateOne(
{
_id: cartId,
"items._id": lineItemId,
},
{
$set: {
"items.$": validatedLineItem,
},
}
)
}
/**
* Sets the email of a cart
* @param {string} cartId - the id of the cart to add email to
@@ -283,7 +338,6 @@ class CartService extends BaseService {
*/
async updateEmail(cartId, email) {
const cart = await this.retrieve(cartId)
const schema = Validator.string()
.email()
.required()
@@ -313,7 +367,6 @@ class CartService extends BaseService {
*/
async updateBillingAddress(cartId, address) {
const cart = await this.retrieve(cartId)
const { value, error } = Validator.address().validate(address)
if (error) {
throw new MedusaError(
@@ -340,7 +393,6 @@ class CartService extends BaseService {
*/
async updateShippingAddress(cartId, address) {
const cart = await this.retrieve(cartId)
const { value, error } = Validator.address().validate(address)
if (error) {
throw new MedusaError(
@@ -360,12 +412,37 @@ class CartService extends BaseService {
}
/**
* A payment method represents a way for the customer to pay. The payment
* method will typically come from one of the payment sessions.
* @typedef {object} PaymentMethod
* @property {string} provider_id - the identifier of the payment method's
* provider
* @property {object} data - the data associated with the payment method
*/
/**
* Retrieves an open payment session from the list of payment sessions
* stored in the cart. If none is an INVALID_DATA error is thrown.
* @param {string} cartId - the id of the cart to retrieve the session from
* @param {string} providerId - the id of the provider the session belongs to
* @return {PaymentMethod} the session
*/
async retrievePaymentSession(cartId, providerId) {
const cart = await this.retrieve(cartId)
const session = cart.payment_sessions.find(
({ provider_id }) => provider_id === providerId
)
if (!session) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The provider_id did not match any open payment sessions`
)
}
return session
}
/**
* Sets a payment method for a cart.
* @param {string} cartId - the id of the cart to add payment method to
@@ -389,7 +466,8 @@ class CartService extends BaseService {
)
}
// Check if the payment method has been authorized.
// The provider service will be able to perform operations on the
// session we are trying to set as the payment method.
const provider = this.paymentProviderService_.retrieveProvider(
paymentMethod.provider_id
)
@@ -413,6 +491,158 @@ class CartService extends BaseService {
)
}
/**
* Creates, updates and sets payment sessions associated with the cart. The
* first time the method is called payment sessions will be created for each
* provider. Additional calls will ensure that payment sessions have correct
* amounts, currencies, etc. as well as make sure to filter payment sessions
* that are not available for the cart's region.
* @param {string} cartId - the id of the cart to set payment session for
* @returns {Promise} the result of the update operation.
*/
async setPaymentSessions(cartId) {
const cart = await this.retrieve(cartId)
const region = await this.regionService_.retrieve(cart.region_id)
// If there are existing payment sessions ensure that these are up to date
let sessions = []
if (cart.payment_sessions && cart.payment_sessions.length) {
sessions = await Promise.all(
cart.payment_sessions.map(async pSession => {
if (!region.payment_providers.includes(pSession.provider_id)) {
return null
}
return this.paymentProviderService_.updateSession(pSession, cart)
})
)
}
// Filter all null sessions
sessions = sessions.filter(s => !!s)
// For all the payment providers in the region make sure to either skip them
// if they already exist or create them if they don't yet exist.
let newSessions = await Promise.all(
region.payment_providers.map(async pId => {
if (sessions.find(s => s.provider_id === pId)) {
return null
}
return this.paymentProviderService_.createSession(pId, cart)
})
)
// Filter null sessions
newSessions = newSessions.filter(s => !!s)
// Update the payment sessions with the concatenated array of updated and
// newly created payment sessions
return this.cartModel_.updateOne(
{
_id: cart._id,
},
{
$set: { payment_sessions: sessions.concat(newSessions) },
}
)
}
/**
* Retrieves one of the open shipping options for the cart.
* @param {string} cartId - the id of the cart to retrieve the option from
* @param {string} optionId - the id of the option to retrieve
* @return {ShippingOption} the option that was found
*/
async retrieveShippingOption(cartId, optionId) {
const cart = await this.retrieve(cartId)
const option = cart.shipping_options.find(({ _id }) => _id === optionId)
if (!option) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The option id doesn't match any available shipping options`
)
}
return option
}
/**
* Adds the shipping method to the list of shipping methods associated with
* the cart.
* @param {string} cartId - the id of the cart to add shipping method to
* @param {ShippingOption} method - the shipping method to add to the cart
* @return {Promise} the result of the update operation
*/
async addShippingMethod(cartId, method) {
const cart = await this.retrieve(cartId)
const { shipping_methods } = cart
const isValid = await this.shippingOptionService_.validateCartOption(
method,
cart
)
if (!isValid) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The selected shipping method cannot be applied to the cart"
)
}
// Go through all existing selected shipping methods and update the one
// that has the same profile as the selected shipping method.
let exists = false
const newMethods = shipping_methods.map(sm => {
if (sm.profile_id === method.profile_id) {
exists = true
return method
}
return sm
})
// If none of the selected methods are for the same profile as the new
// shipping method the exists flag will be false. Therefore we push the new
// method.
if (!exists) {
newMethods.push(method)
}
return this.cartModel_.updateOne(
{
_id: cart._id,
},
{
$set: { shipping_methods: newMethods },
}
)
}
/**
* Finds all shipping options that are available to the cart and stores them
* in shipping_options. The shipping options are retrieved from the shipping
* option service.
* @param {string} cartId - the id of the cart
* @return {Promse} the result of the update operation
*/
async setShippingOptions(cartId) {
const cart = await this.retrieve(cartId)
// Get the shipping options available in the region
const cartOptions = await this.shippingOptionService_.fetchCartOptions(cart)
return this.cartModel_.updateOne(
{
_id: cart._id,
},
{
$set: { shipping_options: cartOptions },
}
)
}
/**
* Set's the region of a cart.
* @param {string} cartId - the id of the cart to set region on
@@ -463,8 +693,8 @@ class CartService extends BaseService {
// Shipping methods are determined by region so the user needs to find a
// new shipping method
if (!_.isEmpty(cart.shipping_method)) {
update.shipping_method = undefined
if (cart.shipping_methods && cart.shipping_methods.length) {
update.shipping_methods = []
}
// Payment methods are region specific so the user needs to find a