feat(core-flows,medusa): Add API to update cart's customer (#10151)

what:

- adds an endpoint that updates a cart's customer

RESOLVES CMRC-718
This commit is contained in:
Riqwan Thamir authored and GitHub committed 2024-11-19 11:44:25 +00:00
1 parent 41dc05d0c9
commit b7044bb3b0
13 files changed
+462 -101

No files matched your search

@@ -1,3 +1,4 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
Modules, Modules,
PriceListStatus, PriceListStatus,
@@ -6,39 +7,19 @@ import {
PromotionRuleOperator, PromotionRuleOperator,
PromotionType, PromotionType,
} from "@medusajs/utils" } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
createAdminUser, createAdminUser,
generatePublishableKey, generatePublishableKey,
generateStoreHeaders, generateStoreHeaders,
} from "../../../../helpers/create-admin-user" } from "../../../../helpers/create-admin-user"
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
import { createAuthenticatedCustomer } from "../../../../modules/helpers/create-authenticated-customer"
jest.setTimeout(100000) jest.setTimeout(100000)
const env = { MEDUSA_FF_MEDUSA_V2: true } const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
const generateStoreHeadersWithCustomer = async ({
api,
storeHeaders,
customer,
}) => {
const registeredCustomerToken = (
await api.post("/auth/customer/emailpass/register", {
email: customer.email,
password: "password",
})
).data.token
return {
headers: {
...storeHeaders.headers,
authorization: `Bearer ${registeredCustomerToken}`,
},
}
}
const shippingAddressData = { const shippingAddressData = {
address_1: "test address 1", address_1: "test address 1",
address_2: "test address 2", address_2: "test address 2",
@@ -136,23 +117,20 @@ medusaIntegrationTestRunner({
const publishableKey = await generatePublishableKey(appContainer) const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey }) storeHeaders = generateStoreHeaders({ publishableKey })
customer = ( const result = await createAuthenticatedCustomer(api, storeHeaders, {
await api.post( first_name: "tony",
"/admin/customers", last_name: "stark",
{ email: "tony@stark-industries.com",
first_name: "tony",
email: "tony@stark-industries.com",
},
adminHeaders
)
).data.customer
storeHeadersWithCustomer = await generateStoreHeadersWithCustomer({
storeHeaders,
api,
customer,
}) })
customer = result.customer
storeHeadersWithCustomer = {
headers: {
...storeHeaders.headers,
authorization: `Bearer ${result.jwt}`,
},
}
await setupTaxStructure(appContainer.resolve(Modules.TAX)) await setupTaxStructure(appContainer.resolve(Modules.TAX))
region = ( region = (
@@ -582,21 +560,17 @@ medusaIntegrationTestRunner({
let otherRegion let otherRegion
beforeEach(async () => { beforeEach(async () => {
cart = ( const cartData = {
await api.post( currency_code: "usd",
`/store/carts`, sales_channel_id: salesChannel.id,
{ region_id: region.id,
email: "tony@stark.com", shipping_address: shippingAddressData,
currency_code: "usd", items: [{ variant_id: product.variants[0].id, quantity: 1 }],
sales_channel_id: salesChannel.id, promo_codes: [promotion.code],
region_id: region.id, }
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }], cart = (await api.post(`/store/carts`, cartData, storeHeaders)).data
promo_codes: [promotion.code], .cart
},
storeHeadersWithCustomer
)
).data.cart
otherRegion = ( otherRegion = (
await api.post( await api.post(
@@ -751,7 +725,7 @@ medusaIntegrationTestRunner({
it("should not generate tax lines if automatic taxes is false", async () => { it("should not generate tax lines if automatic taxes is false", async () => {
let updated = await api.post( let updated = await api.post(
`/store/carts/${cart.id}`, `/store/carts/${cart.id}`,
{ email: "another@tax.com" }, {},
storeHeaders storeHeaders
) )
@@ -776,7 +750,7 @@ medusaIntegrationTestRunner({
updated = await api.post( updated = await api.post(
`/store/carts/${cart.id}`, `/store/carts/${cart.id}`,
{ email: "another@tax.com", region_id: noAutomaticRegion.id }, { region_id: noAutomaticRegion.id },
storeHeaders storeHeaders
) )
@@ -1236,6 +1210,242 @@ medusaIntegrationTestRunner({
}) })
) )
}) })
it("should update email irregardless of registered customer", async () => {
const updateEmailWithoutCustomer = await api.post(
`/store/carts/${cart.id}`,
{ email: "tony@stark.com" },
storeHeaders
)
expect(updateEmailWithoutCustomer.data.cart).toEqual(
expect.objectContaining({
email: "tony@stark.com",
customer: expect.objectContaining({
email: "tony@stark.com",
}),
})
)
const updateCartCustomer = await api.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer
)
expect(updateCartCustomer.data.cart).toEqual(
expect.objectContaining({
email: "tony@stark-industries.com",
customer: expect.objectContaining({
id: customer.id,
email: "tony@stark-industries.com",
}),
})
)
const updateEmailWithCustomer = await api.post(
`/store/carts/${cart.id}`,
{ email: "new@stark.com" },
storeHeaders
)
expect(updateEmailWithCustomer.data.cart).toEqual(
expect.objectContaining({
email: "new@stark.com",
customer: expect.objectContaining({
id: customer.id,
email: "tony@stark-industries.com",
}),
})
)
})
})
describe("POST /store/carts/:id/customer", () => {
beforeEach(async () => {
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
},
storeHeaders
)
).data.cart
})
it("should throw 401 when user is not logged in as a customer", async () => {
const { response } = await api
.post(`/store/carts/${cart.id}/customer`, {}, storeHeaders)
.catch((e) => e)
expect(response.status).toEqual(401)
})
it("should throw error when cart does not exist", async () => {
const { response } = await api
.post(
`/store/carts/does-not-exist/customer`,
{},
storeHeadersWithCustomer
)
.catch((e) => e)
expect(response.status).toEqual(404)
expect(response.data.message).toEqual(
"Cart id not found: does-not-exist"
)
})
it("should throw error when trying to update a cart that belongs to a customer that has an account", async () => {
const customerUpdate1 = await api.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer
)
expect(customerUpdate1.status).toEqual(200)
expect(customerUpdate1.data.cart).toEqual(
expect.objectContaining({
email: customer.email,
customer: expect.objectContaining({
id: customer.id,
email: customer.email,
}),
})
)
const { jwt: jwt2 } = await createAuthenticatedCustomer(
api,
storeHeaders,
{
first_name: "tony2",
last_name: "stark",
email: "tony2@stark-industries.com",
}
)
const storeHeadersWithCustomer2 = {
headers: {
...storeHeaders.headers,
authorization: `Bearer ${jwt2}`,
},
}
const { response } = await api
.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer2
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data.message).toEqual(
"Cannot update cart customer when its assigned to a different customer"
)
})
it("should successfully update cart customer when cart is without customer", async () => {
const updated = await api.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
email: customer.email,
customer: expect.objectContaining({
id: customer.id,
email: customer.email,
}),
})
)
})
it("should successfully update cart customer when cart has a guest customer", async () => {
const guestEmail = "tony@guest.com"
const updatedCart = await api.post(
`/store/carts/${cart.id}`,
{ email: guestEmail },
storeHeadersWithCustomer
)
expect(updatedCart.status).toEqual(200)
expect(updatedCart.data.cart).toEqual(
expect.objectContaining({
email: guestEmail,
customer: expect.objectContaining({
email: guestEmail,
}),
})
)
const updated = await api.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
email: customer.email,
customer: expect.objectContaining({
id: customer.id,
email: customer.email,
}),
})
)
})
it("should successfully update cart customer when customer already owns the cart", async () => {
const guestEmail = "tony@guest.com"
await api.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer
)
const updatedCart = await api.post(
`/store/carts/${cart.id}`,
{ email: guestEmail },
storeHeadersWithCustomer
)
expect(updatedCart.status).toEqual(200)
expect(updatedCart.data.cart).toEqual(
expect.objectContaining({
email: guestEmail,
customer: expect.objectContaining({
email: customer.email,
}),
})
)
const updated = await api.post(
`/store/carts/${cart.id}/customer`,
{},
storeHeadersWithCustomer
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
email: guestEmail,
customer: expect.objectContaining({
id: customer.id,
email: customer.email,
}),
})
)
})
}) })
}) })
}, },
@@ -417,7 +417,8 @@ medusaIntegrationTestRunner({
it("should create cart with logged-in customer", async () => { it("should create cart with logged-in customer", async () => {
const { customer, jwt } = await createAuthenticatedCustomer( const { customer, jwt } = await createAuthenticatedCustomer(
appContainer api,
storeHeaders
) )
const response = await api.post( const response = await api.post(
@@ -1,6 +1,6 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { ICustomerModuleService } from "@medusajs/types" import { ICustomerModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/utils" import { Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
generatePublishableKey, generatePublishableKey,
generateStoreHeaders, generateStoreHeaders,
@@ -31,7 +31,8 @@ medusaIntegrationTestRunner({
it("should create a customer address", async () => { it("should create a customer address", async () => {
const { customer, jwt } = await createAuthenticatedCustomer( const { customer, jwt } = await createAuthenticatedCustomer(
appContainer api,
storeHeaders
) )
const response = await api.post( const response = await api.post(
@@ -1,6 +1,6 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { ICustomerModuleService } from "@medusajs/types" import { ICustomerModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/utils" import { Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
generatePublishableKey, generatePublishableKey,
generateStoreHeaders, generateStoreHeaders,
@@ -32,7 +32,8 @@ medusaIntegrationTestRunner({
it("should delete a customer address", async () => { it("should delete a customer address", async () => {
const { customer, jwt } = await createAuthenticatedCustomer( const { customer, jwt } = await createAuthenticatedCustomer(
appContainer api,
storeHeaders
) )
const address = await customerModuleService.createCustomerAddresses({ const address = await customerModuleService.createCustomerAddresses({
@@ -65,7 +66,7 @@ medusaIntegrationTestRunner({
}) })
it("should fail to delete another customer's address", async () => { it("should fail to delete another customer's address", async () => {
const { jwt } = await createAuthenticatedCustomer(appContainer) const { jwt } = await createAuthenticatedCustomer(api, storeHeaders)
const otherCustomer = await customerModuleService.createCustomers({ const otherCustomer = await customerModuleService.createCustomers({
first_name: "Jane", first_name: "Jane",
@@ -28,7 +28,8 @@ medusaIntegrationTestRunner({
it("should retrieve auth user's customer", async () => { it("should retrieve auth user's customer", async () => {
const { customer, jwt } = await createAuthenticatedCustomer( const { customer, jwt } = await createAuthenticatedCustomer(
appContainer api,
storeHeaders
) )
const response = await api.get(`/store/customers/me`, { const response = await api.get(`/store/customers/me`, {
@@ -41,7 +42,7 @@ medusaIntegrationTestRunner({
id: customer.id, id: customer.id,
first_name: "John", first_name: "John",
last_name: "Doe", last_name: "Doe",
email: "john@me.com", email: "tony@start.com",
}) })
) )
}) })
@@ -1,6 +1,6 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { ICustomerModuleService } from "@medusajs/types" import { ICustomerModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/utils" import { Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
generatePublishableKey, generatePublishableKey,
generateStoreHeaders, generateStoreHeaders,
@@ -32,7 +32,8 @@ medusaIntegrationTestRunner({
it("should get all customer addresses and its count", async () => { it("should get all customer addresses and its count", async () => {
const { customer, jwt } = await createAuthenticatedCustomer( const { customer, jwt } = await createAuthenticatedCustomer(
appContainer api,
storeHeaders
) )
await customerModuleService.createCustomerAddresses([ await customerModuleService.createCustomerAddresses([
@@ -1,6 +1,6 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { ICustomerModuleService } from "@medusajs/types" import { ICustomerModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/utils" import { Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
generatePublishableKey, generatePublishableKey,
generateStoreHeaders, generateStoreHeaders,
@@ -32,7 +32,8 @@ medusaIntegrationTestRunner({
it.only("should update a customer address", async () => { it.only("should update a customer address", async () => {
const { customer, jwt } = await createAuthenticatedCustomer( const { customer, jwt } = await createAuthenticatedCustomer(
appContainer api,
storeHeaders
) )
const address = await customerModuleService.createCustomerAddresses({ const address = await customerModuleService.createCustomerAddresses({
@@ -66,7 +67,7 @@ medusaIntegrationTestRunner({
}) })
it("should fail to update another customer's address", async () => { it("should fail to update another customer's address", async () => {
const { jwt } = await createAuthenticatedCustomer(appContainer) const { jwt } = await createAuthenticatedCustomer(api, storeHeaders)
const otherCustomer = await customerModuleService.createCustomers({ const otherCustomer = await customerModuleService.createCustomers({
first_name: "Jane", first_name: "Jane",
@@ -1,43 +1,39 @@
import { CreateCustomerDTO, MedusaContainer } from "@medusajs/types" import { CreateCustomerDTO } from "@medusajs/types"
import { Modules } from "@medusajs/utils"
import jwt from "jsonwebtoken"
export const createAuthenticatedCustomer = async ( export const createAuthenticatedCustomer = async (
appContainer: MedusaContainer, api: any,
storeHeaders: Record<any, any>,
customerData: Partial<CreateCustomerDTO> = {} customerData: Partial<CreateCustomerDTO> = {}
) => { ) => {
const { http } = appContainer.resolve("configModule").projectConfig const email = customerData.email ?? "tony@start.com"
const authService = appContainer.resolve(Modules.AUTH) const signup = await api.post("/auth/customer/emailpass/register", {
const customerModuleService = appContainer.resolve(Modules.CUSTOMER) email,
password: "secret_password",
const customer = await customerModuleService.createCustomers({
first_name: "John",
last_name: "Doe",
email: "john@me.com",
...customerData,
}) })
const authIdentity = await authService.createAuthIdentities({ const {
provider_identities: [ data: { customer },
{ } = await api.post(
entity_id: "store_user", "/store/customers",
provider: "emailpass",
},
],
app_metadata: {
customer_id: customer.id,
},
})
const token = jwt.sign(
{ {
actor_id: customer.id, email,
actor_type: "customer", first_name: "John",
auth_identity_id: authIdentity.id, last_name: "Doe",
metadata: {},
...customerData,
}, },
http.jwtSecret {
headers: {
authorization: `Bearer ${signup.data.token}`,
...storeHeaders.headers,
},
}
) )
return { customer, authIdentity, jwt: token } const signin = await api.post("/auth/customer/emailpass", {
email,
password: "secret_password",
})
return { customer, jwt: signin.data.token }
} }
@@ -7,6 +7,7 @@ export * from "./create-payment-collection-for-cart"
export * from "./list-shipping-options-for-cart" export * from "./list-shipping-options-for-cart"
export * from "./refresh-payment-collection" export * from "./refresh-payment-collection"
export * from "./update-cart" export * from "./update-cart"
export * from "./update-cart-customer"
export * from "./update-cart-promotions" export * from "./update-cart-promotions"
export * from "./update-line-item-in-cart" export * from "./update-line-item-in-cart"
export * from "./update-tax-lines" export * from "./update-tax-lines"
@@ -0,0 +1,98 @@
import { isDefined, MedusaError } from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
StepResponse,
transform,
when,
WorkflowData,
} from "@medusajs/framework/workflows-sdk"
import { CustomerDTO } from "@medusajs/types"
import { useQueryGraphStep } from "../../common"
import { updateCartsStep } from "../steps"
/**
* This step validates if cart should be updated
*/
export const validateCartCustomerUpdateStep = createStep(
"validate-cart-customer-update",
async function ({
cart,
customer,
}: {
cart: {
customer?: { id: string; has_account: boolean }
}
customer: CustomerDTO
}) {
// If cart customer is the same as the provided customer, succeed early
// Pass in a boolean to not perform a customer update
if (isDefined(cart.customer?.id) && cart.customer.id === customer.id) {
return new StepResponse(false)
}
// If the cart customer already has an account, we can safely assume that its already
// been assigned to a different customer. This falls under cart take over, which isn't being
// handled with this workflow
if (cart.customer?.has_account) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Cannot update cart customer when its assigned to a different customer`
)
}
return new StepResponse(true)
}
)
export const updateCartCustomerWorkflowId = "update-cart-customer"
/**
* This workflow updates cart's customer.
*/
export const updateCartCustomerWorkflow = createWorkflow(
updateCartCustomerWorkflowId,
(input: WorkflowData<{ id: string; customer_id: string }>) => {
const cartQuery = useQueryGraphStep({
entity: "cart",
filters: { id: input.id },
fields: [
"id",
"email",
"customer_id",
"customer.has_account",
"shipping_address.*",
"region.*",
"region.countries.*",
],
options: { throwIfKeyNotFound: true },
}).config({ name: "get-cart" })
const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0])
const customerQuery = useQueryGraphStep({
entity: "customer",
filters: { id: input.customer_id },
fields: ["id", "email"],
options: { throwIfKeyNotFound: true },
}).config({ name: "get-customer" })
const customer = transform(
{ customerQuery },
({ customerQuery }) => customerQuery.data[0]
)
const shouldUpdate = validateCartCustomerUpdateStep({ cart, customer })
when({ shouldUpdate }, ({ shouldUpdate }) => !!shouldUpdate).then(() => {
const cartInput = transform({ cart, customer }, ({ cart, customer }) => [
{
id: cart.id,
customer_id: customer.id,
email: customer.email,
},
])
updateCartsStep(cartInput)
})
}
)
@@ -0,0 +1,30 @@
import { updateCartCustomerWorkflow } from "@medusajs/core-flows"
import { HttpTypes } from "@medusajs/framework/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { refetchCart } from "../../helpers"
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse<HttpTypes.StoreCartResponse>
) => {
const workflow = updateCartCustomerWorkflow(req.scope)
await workflow.run({
input: {
id: req.params.id,
customer_id: req.auth_context?.actor_id,
},
})
const cart = await refetchCart(
req.params.id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ cart })
}
@@ -1,8 +1,10 @@
import { MiddlewareRoute } from "@medusajs/framework/http" import {
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework"
import { authenticate, MiddlewareRoute } from "@medusajs/framework/http"
import { ensurePublishableKeyAndSalesChannelMatch } from "../../utils/middlewares/common/ensure-pub-key-sales-channel-match" import { ensurePublishableKeyAndSalesChannelMatch } from "../../utils/middlewares/common/ensure-pub-key-sales-channel-match"
import { maybeAttachPublishableKeyScopes } from "../../utils/middlewares/common/maybe-attach-pub-key-scopes" import { maybeAttachPublishableKeyScopes } from "../../utils/middlewares/common/maybe-attach-pub-key-scopes"
import { validateAndTransformBody } from "@medusajs/framework"
import { validateAndTransformQuery } from "@medusajs/framework"
import * as OrderQueryConfig from "../orders/query-config" import * as OrderQueryConfig from "../orders/query-config"
import { StoreGetOrderParams } from "../orders/validators" import { StoreGetOrderParams } from "../orders/validators"
import * as QueryConfig from "./query-config" import * as QueryConfig from "./query-config"
@@ -15,6 +17,7 @@ import {
StoreGetCartsCart, StoreGetCartsCart,
StoreRemoveCartPromotions, StoreRemoveCartPromotions,
StoreUpdateCart, StoreUpdateCart,
StoreUpdateCartCustomer,
StoreUpdateCartLineItem, StoreUpdateCartLineItem,
} from "./validators" } from "./validators"
@@ -53,6 +56,18 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
), ),
], ],
}, },
{
method: ["POST"],
matcher: "/store/carts/:id/customer",
middlewares: [
authenticate("customer", ["session", "bearer"]),
validateAndTransformBody(StoreUpdateCartCustomer),
validateAndTransformQuery(
StoreGetCartsCart,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{ {
method: ["POST"], method: ["POST"],
matcher: "/store/carts/:id/line-items", matcher: "/store/carts/:id/line-items",
@@ -91,3 +91,8 @@ export type StoreCreateCartPaymentCollectionType = z.infer<
typeof StoreCreateCartPaymentCollection typeof StoreCreateCartPaymentCollection
> >
export const StoreCreateCartPaymentCollection = z.object({}).strict() export const StoreCreateCartPaymentCollection = z.object({}).strict()
export type StoreUpdateCartCustomerType = z.infer<
typeof StoreUpdateCartCustomer
>
export const StoreUpdateCartCustomer = z.object({}).strict()