feat: Create cart with line items (#6449)

**What**
- Add support for creating a cart with items
- Add endpoint `POST /store/carts/:id/line-items`
- Add `CreateCartWorkflow`
- Add `AddToCartWorkflow`
- Add steps for both workflows

**Testing**
- Endpoints
- Workflows

I would still call this a first iteration, as we are missing a few pieces of the full flow, such as payment sessions, discounts, and taxes.

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Oli Juhl
2024-02-26 14:32:16 +01:00
committed by GitHub
parent ac86362e81
commit 7ebe885ec9
26 changed files with 1100 additions and 157 deletions

View File

@@ -0,0 +1,412 @@
import {
addToCartWorkflow,
createCartWorkflow,
findOrCreateCustomerStepId,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
ICartModuleService,
ICustomerModuleService,
IPricingModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
} from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("Carts workflows", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
let regionModuleService: IRegionModuleService
let scModuleService: ISalesChannelModuleService
let customerModule: ICustomerModuleService
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let remoteLink
let defaultRegion
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
scModuleService = appContainer.resolve(ModuleRegistrationName.SALES_CHANNEL)
customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER)
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
remoteLink = appContainer.resolve("remoteLink")
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await regionModuleService.createDefaultCountriesAndCurrencies()
// Here, so we don't have to create a region for each test
defaultRegion = await regionModuleService.create({
name: "Default Region",
currency_code: "dkk",
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
describe("CreateCartWorkflow", () => {
it("should create a cart", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const salesChannel = await scModuleService.create({
name: "Webshop",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const priceSet = await pricingModule.create({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
])
const { result } = await createCartWorkflow(appContainer).run({
input: {
email: "tony@stark.com",
currency_code: "usd",
region_id: region.id,
sales_channel_id: salesChannel.id,
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
},
})
const cart = await cartModuleService.retrieve(result.id, {
relations: ["items"],
})
expect(cart).toEqual(
expect.objectContaining({
currency_code: "usd",
email: "tony@stark.com",
region_id: region.id,
sales_channel_id: salesChannel.id,
customer_id: expect.any(String),
items: expect.arrayContaining([
expect.objectContaining({
quantity: 1,
unit_price: 3000,
}),
]),
})
)
})
it("should throw when no regions exist", async () => {
await regionModuleService.delete(defaultRegion.id)
const { errors } = await createCartWorkflow(appContainer).run({
input: {
email: "tony@stark.com",
currency_code: "usd",
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "find-one-or-any-region",
handlerType: "invoke",
error: new Error("No regions found"),
},
])
})
it("should throw if sales channel is disabled", async () => {
const api = useApi() as any
const salesChannel = await scModuleService.create({
name: "Webshop",
is_disabled: true,
})
const { errors } = await createCartWorkflow(appContainer).run({
input: {
sales_channel_id: salesChannel.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "find-sales-channel",
handlerType: "invoke",
error: new Error(
`Unable to assign cart to disabled Sales Channel: Webshop`
),
},
])
})
describe("compensation", () => {
it("should delete created customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
email: "tony@stark-industries.com",
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(0)
})
it("should not delete existing customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const customer = await customerModule.create({
email: "tony@stark-industries.com",
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
customer_id: customer.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(1)
})
})
})
describe("AddToCartWorkflow", () => {
it("should add item to cart", async () => {
let cart = await cartModuleService.create({
currency_code: "usd",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const priceSet = await pricingModule.create({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
])
cart = await cartModuleService.retrieve(cart.id, {
select: ["id", "region_id", "currency_code"],
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
cart,
},
})
cart = await cartModuleService.retrieve(cart.id, {
relations: ["items"],
})
expect(cart).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 3000,
quantity: 1,
title: "Test variant",
}),
]),
})
)
})
it("should throw if no price sets for variant exist", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const { errors } = await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
cart,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "get-variant-price-sets",
handlerType: "invoke",
error: new Error(
`Variants with IDs ${product.variants[0].id} do not have a price`
),
},
])
})
it("should throw if variant does not exist", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
})
const { errors } = await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: "prva_foo",
quantity: 1,
},
],
cart,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "validate-variants-exist",
handlerType: "invoke",
error: new Error(`Variants with IDs prva_foo do not exist`),
},
])
})
})
})

View File

@@ -1,11 +1,9 @@
import {
createCartWorkflow,
findOrCreateCustomerStepId,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
ICartModuleService,
ICustomerModuleService,
IPricingModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
} from "@medusajs/types"
@@ -29,6 +27,9 @@ describe("Store Carts API", () => {
let regionModuleService: IRegionModuleService
let scModuleService: ISalesChannelModuleService
let customerModule: ICustomerModuleService
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let remoteLink
let defaultRegion
@@ -41,6 +42,9 @@ describe("Store Carts API", () => {
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
scModuleService = appContainer.resolve(ModuleRegistrationName.SALES_CHANNEL)
customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER)
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
remoteLink = appContainer.resolve("remoteLink")
})
afterAll(async () => {
@@ -51,7 +55,6 @@ describe("Store Carts API", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
// @ts-ignore
await regionModuleService.createDefaultCountriesAndCurrencies()
// Here, so we don't have to create a region for each test
@@ -77,6 +80,58 @@ describe("Store Carts API", () => {
name: "Webshop",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
{
title: "Test variant 2",
},
],
},
])
const [priceSet, priceSetTwo] = await pricingModule.create([
{
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
},
{
prices: [
{
amount: 4000,
currency_code: "usd",
},
],
},
])
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
{
productService: {
variant_id: product.variants[1].id,
},
pricingService: {
price_set_id: priceSetTwo.id,
},
},
])
const api = useApi() as any
const created = await api.post(`/store/carts`, {
@@ -84,6 +139,16 @@ describe("Store Carts API", () => {
currency_code: "usd",
region_id: region.id,
sales_channel_id: salesChannel.id,
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
{
variant_id: product.variants[1].id,
quantity: 2,
},
],
})
expect(created.status).toEqual(200)
@@ -100,6 +165,16 @@ describe("Store Carts API", () => {
customer: expect.objectContaining({
email: "tony@stark.com",
}),
items: expect.arrayContaining([
expect.objectContaining({
quantity: 1,
unit_price: 3000,
}),
expect.objectContaining({
quantity: 2,
unit_price: 4000,
}),
]),
})
)
})
@@ -202,19 +277,6 @@ describe("Store Carts API", () => {
)
})
it("should throw when no regions exist", async () => {
const api = useApi() as any
await regionModuleService.delete(defaultRegion.id)
await expect(
api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
})
).rejects.toThrow()
})
it("should respond 400 bad request on unknown props", async () => {
const api = useApi() as any
@@ -224,93 +286,6 @@ describe("Store Carts API", () => {
})
).rejects.toThrow()
})
it("should throw if sales channel is disabled", async () => {
const api = useApi() as any
const salesChannel = await scModuleService.create({
name: "Webshop",
is_disabled: true,
})
await expect(
api.post(`/store/carts`, {
sales_channel_id: salesChannel.id,
})
).rejects.toThrow()
})
describe("compensation", () => {
it("should delete created customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
email: "tony@stark-industries.com",
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(0)
})
it("should not delete existing customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const customer = await customerModule.create({
email: "tony@stark-industries.com",
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
customer_id: customer.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(1)
})
})
})
describe("GET /store/carts/:id", () => {
@@ -410,4 +385,64 @@ describe("Store Carts API", () => {
)
})
})
describe("POST /store/carts/:id/line-items", () => {
it("should add item to cart", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const priceSet = await pricingModule.create({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
])
const api = useApi() as any
const response = await api.post(`/store/carts/${cart.id}/line-items`, {
variant_id: product.variants[0].id,
quantity: 1,
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 3000,
quantity: 1,
title: "Test variant",
}),
]),
})
)
})
})
})

View File

@@ -2,10 +2,10 @@ import { useApi } from "../../../environment-helpers/use-api"
import { initDb, useDb } from "../../../environment-helpers/use-db"
import {
StepResponse,
WorkflowData,
createStep,
createWorkflow,
StepResponse,
WorkflowData,
} from "@medusajs/workflows-sdk"
import { AxiosInstance } from "axios"
import path from "path"
@@ -200,9 +200,9 @@ export const workflowEngineTestSuite = (env, extraParams = {}) => {
data: expect.objectContaining({
invoke: {
"my-step": {
__type: "WorkflowWorkflowData",
__type: "Symbol(WorkflowWorkflowData)",
output: {
__type: "WorkflowStepResponse",
__type: "Symbol(WorkflowStepResponse)",
output: {
result: "abc",
},
@@ -248,7 +248,7 @@ export const workflowEngineTestSuite = (env, extraParams = {}) => {
data: expect.objectContaining({
invoke: expect.objectContaining({
"my-step-async": {
__type: "WorkflowStepResponse",
__type: "Symbol(WorkflowStepResponse)",
output: {
all: "good",
},