Files
medusa-store/integration-tests/http/__tests__/exchanges/exchanges.spec.ts
Leonardo Benini 9c957e1da0 chore(core-flows): only allow published products in addToCartWorkflow (#13182)
Closes #13163 

I have a few questions about expected behaviour, since this currently breaks some tests:

- Many tests use the productModule to create products, with default status == "draft", and use the addToCart workflow which now throws. Should I change all breaking tests to specify status == "published" whne creating the product? The alternative would be to check the status in the store API route before the workflow but 1. it would be an extra query and 2. the addToCart workflow is only used in the store currently, and even if it was to be used admin-side, it still doesn't make sense to add a draft product to cart

- After this PR an unpublished product would give the same error as a variant that doesn't exist. While imho this is correct, the thrown error (for both) is "Items  do not have a price" which doesn't make much sense(i believe the workflows goes through with an empty variants list and then errors at the price check point). Should I throw a different error when a variant doesn't exists/isn't published?


---

> [!NOTE]
> Enforces that only variants from published products can be added to carts, adds status fetching, refines errors, and updates tests to use ProductStatus.PUBLISHED.
> 
> - **Core Flows**:
>   - addToCart: Validate variants exist and belong to `product.status = PUBLISHED`; throw clear `INVALID_DATA` when not found/unpublished.
>   - Data fetching: Include `product.status` in `cart` and `order` variant field selections.
> - **Tests/Fixtures**:
>   - Update integration tests to set `status: ProductStatus.PUBLISHED` when creating products and import `ProductStatus` where needed.
>   - Add cases for unpublished products and non-existent variants producing the new error message.
> 
> <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ca72532e957964d2d8e6bcecbb0905054c677ded. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup>
2025-10-02 12:31:53 +00:00

919 lines
24 KiB
TypeScript

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import {
ContainerRegistrationKeys,
Modules,
ProductStatus,
RuleOperator,
} from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
} from "../../../helpers/create-admin-user"
import { setupTaxStructure } from "../../../modules/__tests__/fixtures/tax"
jest.setTimeout(300000)
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
let order, order2
let returnShippingOption
let outboundShippingOption
let shippingProfile
let fulfillmentSet
let returnReason
let inventoryItem
let inventoryItemExtra
let inventoryItemExtra2
let location
let productExtra
let productExtra2
const shippingProviderId = "manual_test-provider"
beforeEach(async () => {
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
const region = (
await api.post(
"/admin/regions",
{
name: "test-region",
currency_code: "usd",
},
adminHeaders
)
).data.region
shippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{
name: "Test",
type: "default",
},
adminHeaders
)
).data.shipping_profile
const customer = (
await api.post(
"/admin/customers",
{
first_name: "joe",
email: "joe@admin.com",
},
adminHeaders
)
).data.customer
const salesChannel = (
await api.post(
"/admin/sales-channels",
{
name: "Test channel",
},
adminHeaders
)
).data.sales_channel
const product = (
await api.post(
"/admin/products",
{
title: "Test product",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
options: [{ title: "size", values: ["large", "small"] }],
variants: [
{
title: "Test variant",
sku: "test-variant",
options: { size: "large" },
prices: [
{
currency_code: "usd",
amount: 10,
},
],
},
],
},
adminHeaders
)
).data.product
productExtra = (
await api.post(
"/admin/products",
{
title: "Extra product",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
options: [{ title: "size", values: ["large", "small"] }],
variants: [
{
title: "my variant",
sku: "variant-sku",
options: { size: "large" },
prices: [
{
currency_code: "usd",
amount: 123456.1234657890123456789,
},
],
},
],
},
adminHeaders
)
).data.product
productExtra2 = (
await api.post(
"/admin/products",
{
title: "Extra product 2, same price",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
options: [{ title: "size", values: ["large", "small"] }],
variants: [
{
title: "my variant 2",
sku: "variant-sku-2",
options: { size: "large" },
prices: [
{
currency_code: "usd",
amount: 25,
},
],
},
],
},
adminHeaders
)
).data.product
returnReason = (
await api.post(
"/admin/return-reasons",
{
value: "return-reason-test",
label: "Test return reason",
},
adminHeaders
)
).data.return_reason
const orderModule = container.resolve(Modules.ORDER)
order = await orderModule.createOrders({
region_id: region.id,
email: "foo@bar.com",
items: [
{
title: "Custom Item 2",
variant_id: product.variants[0].id,
quantity: 2,
unit_price: 25,
},
],
sales_channel_id: salesChannel.id,
shipping_address: {
first_name: "Test",
last_name: "Test",
address_1: "Test",
city: "Test",
country_code: "US",
postal_code: "12345",
phone: "12345",
},
billing_address: {
first_name: "Test",
last_name: "Test",
address_1: "Test",
city: "Test",
country_code: "US",
postal_code: "12345",
},
shipping_methods: [
{
name: "Test shipping method",
amount: 10,
data: {},
tax_lines: [
{
description: "shipping Tax 1",
tax_rate_id: "tax_usa_shipping",
code: "code",
rate: 10,
},
],
},
],
currency_code: "usd",
customer_id: customer.id,
})
order2 = await orderModule.createOrders({
region_id: region.id,
email: "foo@bar2.com",
items: [
{
title: "Custom Iasdasd2",
quantity: 1,
unit_price: 20,
},
],
sales_channel_id: salesChannel.id,
shipping_address: {
first_name: "Test",
last_name: "Test",
address_1: "Test",
city: "Test",
country_code: "US",
postal_code: "12345",
phone: "12345",
},
billing_address: {
first_name: "Test",
last_name: "Test",
address_1: "Test",
city: "Test",
country_code: "US",
postal_code: "12345",
},
currency_code: "usd",
customer_id: customer.id,
})
location = (
await api.post(
`/admin/stock-locations`,
{
name: "Test location",
},
adminHeaders
)
).data.stock_location
location = (
await api.post(
`/admin/stock-locations/${location.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
name: "Test",
type: "test-type",
},
adminHeaders
)
).data.stock_location
fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${location.fulfillment_sets[0].id}/service-zones`,
{
name: "Test",
geo_zones: [{ type: "country", country_code: "us" }],
},
adminHeaders
)
).data.fulfillment_set
inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "inv-1234" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/inventory-items/${inventoryItem.id}/location-levels`,
{
location_id: location.id,
stocked_quantity: 2,
},
adminHeaders
)
inventoryItemExtra = (
await api.get(`/admin/inventory-items?sku=variant-sku`, adminHeaders)
).data.inventory_items[0]
inventoryItemExtra2 = (
await api.get(`/admin/inventory-items?sku=variant-sku-2`, adminHeaders)
).data.inventory_items[0]
await api.post(
`/admin/inventory-items/${inventoryItemExtra.id}/location-levels`,
{
location_id: location.id,
stocked_quantity: 4,
},
adminHeaders
)
await api.post(
`/admin/inventory-items/${inventoryItemExtra2.id}/location-levels`,
{
location_id: location.id,
stocked_quantity: 2,
},
adminHeaders
)
const remoteLink = container.resolve(
ContainerRegistrationKeys.REMOTE_LINK
)
await remoteLink.create([
{
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
[Modules.FULFILLMENT]: {
fulfillment_provider_id: shippingProviderId,
},
},
{
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
[Modules.FULFILLMENT]: {
fulfillment_set_id: fulfillmentSet.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.INVENTORY]: {
inventory_item_id: inventoryItem.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: productExtra.variants[0].id,
},
[Modules.INVENTORY]: {
inventory_item_id: inventoryItemExtra.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: productExtra2.variants[0].id,
},
[Modules.INVENTORY]: {
inventory_item_id: inventoryItemExtra2.id,
},
},
])
// create reservation for inventory item that is initially on the order
const inventoryModule = container.resolve(Modules.INVENTORY)
await inventoryModule.createReservationItems([
{
inventory_item_id: inventoryItem.id,
location_id: location.id,
quantity: 2,
line_item_id: order.items[0].id,
},
])
const shippingOptionPayload = {
name: "Return shipping",
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: shippingProviderId,
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{
currency_code: "usd",
amount: 1000,
},
],
rules: [
{
operator: RuleOperator.EQ,
attribute: "is_return",
value: "true",
},
],
}
const outboundShippingOptionPayload = {
name: "Oubound shipping",
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: shippingProviderId,
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{
currency_code: "usd",
amount: 20,
},
],
rules: [
{
operator: RuleOperator.EQ,
attribute: "is_return",
value: "false",
},
{
operator: RuleOperator.EQ,
attribute: "enabled_in_store",
value: "true",
},
],
}
outboundShippingOption = (
await api.post(
"/admin/shipping-options",
outboundShippingOptionPayload,
adminHeaders
)
).data.shipping_option
returnShippingOption = (
await api.post(
"/admin/shipping-options",
shippingOptionPayload,
adminHeaders
)
).data.shipping_option
const item = order.items[0]
await api.post(
`/admin/orders/${order.id}/fulfillments`,
{
items: [
{
id: item.id,
quantity: 2,
},
],
},
adminHeaders
)
await api.post(
`/admin/orders/${order2.id}/fulfillments`,
{
items: [
{
id: order2.items[0].id,
quantity: 1,
},
],
},
adminHeaders
)
await setupTaxStructure(container.resolve(Modules.TAX))
})
describe("Exchanges lifecycle", () => {
it("test full exchange flow", async () => {
const orderBefore = (
await api.get(`/admin/orders/${order.id}`, adminHeaders)
).data.order
let result = await api.post(
"/admin/exchanges",
{
order_id: order.id,
description: "Test",
},
adminHeaders
)
expect(result.data.exchange.created_by).toEqual(expect.any(String))
const exchangeId = result.data.exchange.id
const item = order.items[0]
result = await api.post(
`/admin/exchanges/${exchangeId}/inbound/items`,
{
items: [
{
id: item.id,
reason_id: returnReason.id,
quantity: 2,
},
],
},
adminHeaders
)
// New Item
result = await api.post(
`/admin/exchanges/${exchangeId}/outbound/items`,
{
items: [
{
variant_id: productExtra2.variants[0].id,
quantity: 2,
},
],
},
adminHeaders
)
result = await api.post(
`/admin/exchanges/${exchangeId}/request`,
{},
adminHeaders
)
const returnId = result.data.exchange.return_id
result = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
.order
expect(orderBefore.total).toBe(61)
expect(result.total).toBe(112)
// receive return
await api.post(`/admin/returns/${returnId}/receive`, {}, adminHeaders)
await api.post(
`/admin/returns/${returnId}/receive-items`,
{
items: [
{
id: item.id,
quantity: 2,
},
],
},
adminHeaders
)
await api.post(
`/admin/returns/${returnId}/receive/confirm`,
{},
adminHeaders
)
result = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
.order
expect(orderBefore.total).toBe(61)
expect(result.total).toBe(62) // +1 is from taxes of the new item
})
it("Full flow with 2 orders", async () => {
let result = await api.post(
"/admin/exchanges",
{
order_id: order.id,
description: "Test",
},
adminHeaders
)
expect(result.data.exchange.created_by).toEqual(expect.any(String))
const exchangeId = result.data.exchange.id
let r2 = await api.post(
"/admin/exchanges",
{
order_id: order2.id,
},
adminHeaders
)
const exchangeId2 = r2.data.exchange.id
const item2 = order2.items[0]
result = await api.post(
`/admin/exchanges/${exchangeId2}/inbound/items`,
{
items: [
{
id: item2.id,
quantity: 1,
},
],
},
adminHeaders
)
await api.post(
`/admin/exchanges/${exchangeId2}/inbound/shipping-method`,
{
shipping_option_id: returnShippingOption.id,
},
adminHeaders
)
const { response } = await api
.post(`/admin/exchanges/${exchangeId2}/request`, {}, adminHeaders)
.catch((e) => e)
expect(response.data).toEqual({
type: "invalid_data",
message:
"Order exchange request should have at least 1 item inbound and 1 item outbound",
})
await api.post(
`/admin/exchanges/${exchangeId2}/outbound/items`,
{
items: [
{
variant_id: productExtra.variants[0].id,
quantity: 2,
},
],
},
adminHeaders
)
await api.post(
`/admin/exchanges/${exchangeId2}/request`,
{},
adminHeaders
)
const item = order.items[0]
result = await api.post(
`/admin/exchanges/${exchangeId}/inbound/items`,
{
items: [
{
id: item.id,
reason_id: returnReason.id,
quantity: 2,
},
],
},
adminHeaders
)
await api.post(
`/admin/exchanges/${exchangeId}/inbound/shipping-method`,
{
shipping_option_id: returnShippingOption.id,
},
adminHeaders
)
// updated the requested quantity
const updateReturnItemActionId =
result.data.order_preview.items[0].actions[0].id
result = await api.post(
`/admin/exchanges/${exchangeId}/inbound/items/${updateReturnItemActionId}`,
{
quantity: 1,
},
adminHeaders
)
// New Items
result = await api.post(
`/admin/exchanges/${exchangeId}/outbound/items`,
{
items: [
{
variant_id: productExtra.variants[0].id,
quantity: 2,
},
],
},
adminHeaders
)
result = await api.post(
`/admin/exchanges/${exchangeId}/request`,
{},
adminHeaders
)
result = (
await api.get(
`/admin/exchanges?fields=+metadata,*additional_items`,
adminHeaders
)
).data.exchanges
expect(result).toHaveLength(2)
expect(result[0].additional_items).toHaveLength(1)
expect(result[0].canceled_at).toBeNull()
const return_ = (
await api.get(
`/admin/returns/${result[0].return_id}?fields=*fulfillments`,
adminHeaders
)
).data.return
expect(return_.fulfillments).toHaveLength(1)
expect(return_.fulfillments[0].canceled_at).toBeNull()
// all exchange return fulfillments should be canceled before canceling the exchange
await api.post(
`/admin/fulfillments/${return_.fulfillments[0].id}/cancel`,
{},
adminHeaders
)
await api.post(
`/admin/exchanges/${exchangeId}/cancel`,
{},
adminHeaders
)
result = (
await api.get(
`/admin/exchanges?fields=*additional_items`,
adminHeaders
)
).data.exchanges
expect(result[0].canceled_at).toBeDefined()
})
describe("with inbound and outbound items", () => {
let exchange
let orderPreview
beforeEach(async () => {
exchange = (
await api.post(
"/admin/exchanges",
{
order_id: order.id,
description: "Test",
},
adminHeaders
)
).data.exchange
const item = order.items[0]
await api.post(
`/admin/exchanges/${exchange.id}/inbound/items`,
{
items: [
{
id: item.id,
reason_id: returnReason.id,
quantity: 2,
},
],
},
adminHeaders
)
await api.post(
`/admin/exchanges/${exchange.id}/inbound/shipping-method`,
{ shipping_option_id: returnShippingOption.id },
adminHeaders
)
await api.post(
`/admin/exchanges/${exchange.id}/outbound/items`,
{
items: [
{
variant_id: productExtra.variants[0].id,
quantity: 2,
},
],
},
adminHeaders
)
await api.post(
`/admin/exchanges/${exchange.id}/outbound/shipping-method`,
{ shipping_option_id: outboundShippingOption.id },
adminHeaders
)
orderPreview = (
await api.get(`/admin/orders/${order.id}/preview`, adminHeaders)
).data.order
})
it("should remove outbound shipping method when outbound items are completely removed", async () => {
orderPreview = (
await api.get(`/admin/orders/${order.id}/preview`, adminHeaders)
).data.order
const exchangeItems = orderPreview.items.filter(
(item) =>
!!item.actions?.find((action) => action.action === "ITEM_ADD")
)
const exchangeShippingMethods = orderPreview.shipping_methods.filter(
(item) =>
!!item.actions?.find(
(action) =>
action.action === "SHIPPING_ADD" && !action.return_id
)
)
expect(exchangeItems).toHaveLength(1)
expect(exchangeShippingMethods).toHaveLength(1)
await api.delete(
`/admin/exchanges/${exchange.id}/outbound/items/${exchangeItems[0].actions[0].id}`,
adminHeaders
)
orderPreview = (
await api.get(`/admin/orders/${order.id}/preview`, adminHeaders)
).data.order
const updatedExchangeItems = orderPreview.items.filter(
(item) =>
!!item.actions?.find((action) => action.action === "ITEM_ADD")
)
const updatedClaimShippingMethods =
orderPreview.shipping_methods.filter(
(item) =>
!!item.actions?.find(
(action) =>
action.action === "SHIPPING_ADD" && !action.return_id
)
)
expect(updatedExchangeItems).toHaveLength(0)
expect(updatedClaimShippingMethods).toHaveLength(0)
})
it("should remove inbound shipping method when inbound items are completely removed", async () => {
orderPreview = (
await api.get(`/admin/orders/${order.id}/preview`, adminHeaders)
).data.order
const exchangeItems = orderPreview.items.filter(
(item) =>
!!item.actions?.find((action) => action.action === "RETURN_ITEM")
)
const exchangeShippingMethods = orderPreview.shipping_methods.filter(
(item) =>
!!item.actions?.find(
(action) =>
action.action === "SHIPPING_ADD" && !!action.return_id
)
)
expect(exchangeItems).toHaveLength(1)
expect(exchangeShippingMethods).toHaveLength(1)
await api.delete(
`/admin/exchanges/${exchange.id}/inbound/items/${exchangeItems[0].actions[0].id}`,
adminHeaders
)
orderPreview = (
await api.get(`/admin/orders/${order.id}/preview`, adminHeaders)
).data.order
const updatedExchangeItems = orderPreview.items.filter(
(item) =>
!!item.actions?.find((action) => action.action === "RETURN_ITEM")
)
const updatedClaimShippingMethods =
orderPreview.shipping_methods.filter(
(item) =>
!!item.actions?.find(
(action) =>
action.action === "SHIPPING_ADD" && !!action.return_id
)
)
expect(updatedExchangeItems).toHaveLength(0)
expect(updatedClaimShippingMethods).toHaveLength(0)
})
})
})
},
})