feat(core-flows): cart complete shipping validate (#10984)

**What**
- validate that there is a shipping method if any of the line items have requires_shipping=true
- validate that products shipping profile is supported by a shipping method on the cart
- update tests

---

CLOSES CMRC-683
This commit is contained in:
Frane Polić
2025-02-04 10:10:08 +00:00
committed by GitHub
parent 3cf4307296
commit 462c3e8057
9 changed files with 512 additions and 118 deletions
@@ -96,7 +96,11 @@ medusaIntegrationTestRunner({
).data.region
product = (
await api.post("/admin/products", { ...medusaTshirtProduct, shipping_profile_id: shippingProfile.id }, adminHeaders)
await api.post(
"/admin/products",
{ ...medusaTshirtProduct, shipping_profile_id: shippingProfile.id },
adminHeaders
)
).data.product
salesChannel = (
@@ -995,87 +999,89 @@ medusaIntegrationTestRunner({
})
describe("POST /store/carts/:id/complete", () => {
beforeEach(async () => {
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
})
it("should successfully complete cart", async () => {
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 1500,
compare_at_unit_price: null,
quantity: 1,
}),
]),
})
)
})
describe("with sale price lists", () => {
let priceList
describe("should successfully complete cart", () => {
beforeEach(async () => {
priceList = (
const stockLocation = (
await api.post(
`/admin/price-lists`,
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
title: "test price list",
description: "test",
status: PriceListStatus.ACTIVE,
type: PriceListType.SALE,
prices: [
{
amount: 350,
currency_code: "usd",
variant_id: product.variants[0].id,
},
],
name: `Test-${shippingProfile.id}`,
type: "test-type",
},
adminHeaders
)
).data.price_list
).data.stock_location.fulfillment_sets
const fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: `Test-${shippingProfile.id}`,
geo_zones: [{ type: "country", country_code: "US" }],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/store/carts/${cart.id}/line-items`,
{ variant_id: product.variants[0].id, quantity: 1 },
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] },
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [{ currency_code: "usd", amount: 1000 }],
rules: [],
},
adminHeaders
)
).data.shipping_option
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
@@ -1094,27 +1100,297 @@ medusaIntegrationTestRunner({
)
})
it("should add price from price list and set compare_at_unit_price for order item", async () => {
it("should successfully complete cart", async () => {
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{ variant_id: product.variants[0].id, quantity: 1 },
{},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 350,
compare_at_unit_price: 1500,
is_tax_inclusive: true,
quantity: 2,
unit_price: 1500,
compare_at_unit_price: null,
quantity: 1,
}),
]),
})
)
})
describe("with sale price lists", () => {
let priceList
beforeEach(async () => {
priceList = (
await api.post(
`/admin/price-lists`,
{
title: "test price list",
description: "test",
status: PriceListStatus.ACTIVE,
type: PriceListType.SALE,
prices: [
{
amount: 350,
currency_code: "usd",
variant_id: product.variants[0].id,
},
],
},
adminHeaders
)
).data.price_list
await api.post(
`/store/carts/${cart.id}/line-items`,
{ variant_id: product.variants[0].id, quantity: 1 },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
})
it("should add price from price list and set compare_at_unit_price for order item", async () => {
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{ variant_id: product.variants[0].id, quantity: 1 },
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 350,
compare_at_unit_price: 1500,
is_tax_inclusive: true,
quantity: 2,
}),
]),
})
)
})
})
})
describe("shipping validation", () => {
it("should fail to complete the cart if no shipping method is selected and items require shipping", async () => {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
const response = await api
.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
.catch((e) => e)
expect(response.response.status).toEqual(400)
expect(response.response.data.message).toEqual(
"No shipping method selected but the cart contains items that require shipping."
)
})
it("should fail to complete the cart if the shipping profile of a product is not supported by the shipping method", async () => {
const stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
const shippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: `test-${stockLocation.id}`, type: "default" },
adminHeaders
)
).data.shipping_profile
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
name: `Test-${shippingProfile.id}`,
type: "test-type",
},
adminHeaders
)
).data.stock_location.fulfillment_sets
const fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: `Test-${shippingProfile.id}`,
geo_zones: [{ type: "country", country_code: "US" }],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] },
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [{ currency_code: "usd", amount: 1000 }],
rules: [],
},
adminHeaders
)
).data.shipping_option
const specialShippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: "special-shipping-profile", type: "special" },
adminHeaders
)
).data.shipping_profile
const product = (
await api.post(
`/admin/products`,
{
title: "test product",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: "usd",
},
],
},
],
shipping_profile_id: specialShippingProfile.id, // --> product has a different shipping profile than
},
adminHeaders
)
).data.product
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
const response = await api
.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
.catch((e) => e)
expect(response.response.status).toEqual(400)
expect(response.response.data.message).toEqual(
"The cart items require shipping profiles that are not satisfied by the current shipping methods"
)
})
})
})
@@ -29,11 +29,17 @@ export async function createOrderSeeder({
stockChannelOverride?: AdminStockLocation
additionalProducts?: { variant_id: string; quantity: number }[]
inventoryItemOverride?: AdminInventoryItem
shippingProfileOverride?: AdminShippingProfile
shippingProfileOverride?: AdminShippingProfile | AdminShippingProfile[]
withoutShipping?: boolean
}) {
const publishableKey = await generatePublishableKey(container)
const shippingProfileOverrideArray = !shippingProfileOverride
? undefined
: Array.isArray(shippingProfileOverride)
? shippingProfileOverride
: [shippingProfileOverride]
const storeHeaders =
storeHeaderOverride ??
generateStoreHeaders({
@@ -92,7 +98,7 @@ export async function createOrderSeeder({
)
const shippingProfile =
shippingProfileOverride ??
shippingProfileOverrideArray?.[0] ??
(
await api.post(
`/admin/shipping-profiles`,
@@ -168,29 +174,38 @@ export async function createOrderSeeder({
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{ currency_code: "usd", amount: 1000 },
{ region_id: region.id, amount: 1100 },
],
rules: [],
},
adminHeaders
)
).data.shipping_option
/**
* Create shipping options for each shipping profile provided
*/
const shippingOptions = await Promise.all(
(shippingProfileOverrideArray || [shippingProfile]).map(async (sp) => {
return (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: sp.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{ currency_code: "usd", amount: 1000 },
{ region_id: region.id, amount: 1100 },
],
rules: [],
},
adminHeaders
)
).data.shipping_option
})
)
const shippingOption = shippingOptions[0]
const cart = (
await api.post(
@@ -226,10 +241,15 @@ export async function createOrderSeeder({
).data.cart
if (!withoutShipping) {
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
// Create shipping methods for each shipping option so shipping profiles of products in the cart are supported
await Promise.all(
shippingOptions.map(async (so) => {
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: so.id },
storeHeaders
)
})
)
}
@@ -798,7 +798,7 @@ medusaIntegrationTestRunner({
],
stockChannelOverride,
inventoryItemOverride,
shippingProfileOverride: shippingProfile,
shippingProfileOverride: [shippingProfile, shippingProfileOverride],
})
order = seeder.order
order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
@@ -932,6 +932,7 @@ medusaIntegrationTestRunner({
.post(
`/admin/orders/${order.id}/fulfillments`,
{
shipping_option_id: seeder.shippingOption.id, // shipping option with the "regular" shipping profile
location_id: stockChannelOverride.id,
items: [{ id: orderItemId, quantity: 1 }],
},
@@ -946,6 +947,9 @@ medusaIntegrationTestRunner({
})
it("should only create fulfillments grouped by shipping requirement", async () => {
const item1Id = order.items.find((i) => i.requires_shipping).id
const item2Id = order.items.find((i) => !i.requires_shipping).id
const {
response: { data },
} = await api
@@ -955,11 +959,11 @@ medusaIntegrationTestRunner({
location_id: seeder.stockLocation.id,
items: [
{
id: order.items[0].id,
id: item1Id,
quantity: 1,
},
{
id: order.items[1].id,
id: item2Id,
quantity: 1,
},
],
@@ -979,7 +983,7 @@ medusaIntegrationTestRunner({
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
{
location_id: seeder.stockLocation.id,
items: [{ id: order.items[0].id, quantity: 1 }],
items: [{ id: item1Id, quantity: 1 }],
},
adminHeaders
)
@@ -992,7 +996,7 @@ medusaIntegrationTestRunner({
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
{
location_id: seeder.stockLocation.id,
items: [{ id: order.items[1].id, quantity: 1 }],
items: [{ id: item2Id, quantity: 1 }],
},
adminHeaders
)