fix(medusa): sales_channel_id middleware manipulation (#12234)

* fix(medusa): sales_channel_id middleware manipulation leading to lost of the sc

* fix(medusa): sales_channel_id middleware manipulation leading to lost of the sc

* add unit tests

* add unit tests

* improve

* integration tests
This commit is contained in:
Adrien de Peretti
2025-04-20 17:42:59 +02:00
committed by GitHub
parent a8a7af46a6
commit 49c526399e
5 changed files with 494 additions and 49 deletions

View File

@@ -1478,7 +1478,7 @@ medusaIntegrationTestRunner({
describe("with inventory items", () => {
let location1
let location2
let salesChannel1
let salesChannel1, salesChannel2
let publishableKey1
beforeEach(async () => {
@@ -1503,6 +1503,11 @@ medusaIntegrationTestRunner({
[product.id, product2.id]
)
salesChannel2 = await createSalesChannel(
{ name: "sales channel test 2" },
[product.id, product2.id]
)
const api1Res = await api.post(
`/admin/api-keys`,
{ title: "Test publishable KEY", type: ApiKeyType.PUBLISHABLE },
@@ -1721,6 +1726,63 @@ medusaIntegrationTestRunner({
)
})
it("should list all inventory items for a variant in a given sales channel passed as a query param AND when there are multiple sales channels associated with the publishable key", async () => {
await api.post(
`/admin/api-keys/${publishableKey1.id}/sales-channels`,
{ add: [salesChannel2.id] },
adminHeaders
)
let response = await api.get(
`/store/products?sales_channel_id[]=${salesChannel1.id}&fields=variants.inventory_items.inventory.location_levels.*`,
{ headers: { "x-publishable-api-key": publishableKey1.token } }
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(2)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.id,
variants: expect.arrayContaining([
expect.objectContaining({
inventory_items: expect.arrayContaining([
expect.objectContaining({
inventory_item_id: inventoryItem1.id,
}),
expect.objectContaining({
inventory_item_id: inventoryItem2.id,
}),
]),
}),
]),
}),
])
)
})
it("should throw when multiple sales channels are passed as a query param AND there are multiple sales channels associated with the publishable key", async () => {
await api.post(
`/admin/api-keys/${publishableKey1.id}/sales-channels`,
{ add: [salesChannel2.id] },
adminHeaders
)
let error = await api
.get(
`/store/products?sales_channel_id[]=${salesChannel1.id}&sales_channel_id[]=${salesChannel2.id}&fields=variants.inventory_quantity`,
{ headers: { "x-publishable-api-key": publishableKey1.token } }
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message:
"Inventory availability cannot be calculated in the given context. Either provide a single sales channel id or configure a single sales channel in the publishable key",
type: "invalid_data",
})
})
it("should return inventory quantity when variant's manage_inventory is true", async () => {
await api.post(
`/admin/products/${product.id}/variants/${variant.id}/inventory-items`,

View File

@@ -0,0 +1,136 @@
import { MedusaStoreRequest } from "@medusajs/framework/http"
import { MedusaError } from "@medusajs/framework/utils"
import { NextFunction } from "express"
import {
transformAndValidateSalesChannelIds,
filterByValidSalesChannels,
} from "../filter-by-valid-sales-channels"
describe("filter-by-valid-sales-channels", () => {
describe("transformAndValidateSalesChannelIds", () => {
let req: Partial<MedusaStoreRequest>
beforeEach(() => {
req = {
publishable_key_context: {
key: "test-key",
sales_channel_ids: ["sc-1", "sc-2"],
},
validatedQuery: {},
}
})
it("should return sales channel ids from request when they exist and are in publishable key", () => {
req.validatedQuery = { sales_channel_id: ["sc-1"] }
const result = transformAndValidateSalesChannelIds(
req as MedusaStoreRequest
)
expect(result).toEqual(["sc-1"])
})
it("should handle sales_channel_id as string and transform to array", () => {
req.validatedQuery = { sales_channel_id: "sc-2" }
const result = transformAndValidateSalesChannelIds(
req as MedusaStoreRequest
)
expect(result).toEqual(["sc-2"])
})
it("should throw error when requested sales channel is not in publishable key", () => {
req.validatedQuery = { sales_channel_id: ["sc-3"] }
expect(() => {
transformAndValidateSalesChannelIds(req as MedusaStoreRequest)
}).toThrow(MedusaError)
})
it("should return sales channel ids from publishable key when no ids in request", () => {
req.validatedQuery = {}
const result = transformAndValidateSalesChannelIds(
req as MedusaStoreRequest
)
expect(result).toEqual(["sc-1", "sc-2"])
})
it("should return empty array when no sales channel ids in publishable key or request", () => {
req.publishable_key_context = {
key: "test-key",
sales_channel_ids: [],
}
req.validatedQuery = {}
const result = transformAndValidateSalesChannelIds(
req as MedusaStoreRequest
)
expect(result).toEqual([])
})
})
describe("filterByValidSalesChannels", () => {
let req: Partial<MedusaStoreRequest>
let res: any
let next: NextFunction
let middleware: ReturnType<typeof filterByValidSalesChannels>
beforeEach(() => {
req = {
publishable_key_context: {
key: "test-key",
sales_channel_ids: ["sc-1", "sc-2"],
},
validatedQuery: {},
filterableFields: {},
}
res = {}
next = jest.fn()
middleware = filterByValidSalesChannels()
})
it("should set filterableFields.sales_channel_id and call next", async () => {
await middleware(req as MedusaStoreRequest, res, next)
expect(req.filterableFields!.sales_channel_id).toEqual(["sc-1", "sc-2"])
expect(next).toHaveBeenCalled()
})
it("should throw error when no sales channels available", async () => {
req.publishable_key_context = {
key: "test-key",
sales_channel_ids: [],
}
await expect(
middleware(req as MedusaStoreRequest, res, next)
).rejects.toThrow(
"Publishable key needs to have a sales channel configured"
)
expect(next).not.toHaveBeenCalled()
})
it("should use only sales channels from request that are in publishable key", async () => {
req.validatedQuery = { sales_channel_id: ["sc-1"] }
await middleware(req as MedusaStoreRequest, res, next)
expect(req.filterableFields!.sales_channel_id).toEqual(["sc-1"])
expect(next).toHaveBeenCalled()
})
it("should handle sales_channel_id as string in request", async () => {
req.validatedQuery = { sales_channel_id: "sc-2" }
await middleware(req as MedusaStoreRequest, res, next)
expect(req.filterableFields!.sales_channel_id).toEqual(["sc-2"])
expect(next).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,233 @@
import {
ContainerRegistrationKeys,
deepCopy,
getTotalVariantAvailability,
getVariantAvailability,
MedusaError,
} from "@medusajs/framework/utils"
import { MedusaRequest, MedusaStoreRequest } from "@medusajs/framework/http"
import {
wrapVariantsWithTotalInventoryQuantity,
wrapVariantsWithInventoryQuantityForSalesChannel,
} from "../variant-inventory-quantity"
jest.mock("@medusajs/framework/utils", () => {
const originalModule = jest.requireActual("@medusajs/framework/utils")
return {
...originalModule,
getTotalVariantAvailability: jest.fn(),
getVariantAvailability: jest.fn(),
}
})
describe("variant-inventory-quantity", () => {
let req
let mockQuery
let variants
beforeEach(() => {
mockQuery = jest.fn()
variants = [
{ id: "variant-1", manage_inventory: true },
{ id: "variant-2", manage_inventory: true },
{ id: "variant-3", manage_inventory: false },
]
req = {
scope: {
resolve: jest.fn().mockReturnValue(mockQuery),
},
}
})
afterEach(() => {
jest.clearAllMocks()
})
describe("wrapVariantsWithTotalInventoryQuantity", () => {
it("should not call getTotalVariantAvailability when variants array is empty", async () => {
await wrapVariantsWithTotalInventoryQuantity(req as MedusaRequest, [])
expect(getTotalVariantAvailability).not.toHaveBeenCalled()
})
it("should call getTotalVariantAvailability with correct parameters", async () => {
const mockAvailability = {
"variant-1": { availability: 10 },
"variant-2": { availability: 5 },
}
;(getTotalVariantAvailability as jest.Mock).mockResolvedValueOnce(
mockAvailability
)
await wrapVariantsWithTotalInventoryQuantity(
req as MedusaRequest,
variants
)
expect(req.scope.resolve).toHaveBeenCalledWith(
ContainerRegistrationKeys.QUERY
)
expect(getTotalVariantAvailability).toHaveBeenCalledWith(mockQuery, {
variant_ids: ["variant-1", "variant-2", "variant-3"],
})
})
it("should update inventory_quantity for variants with manage_inventory=true", async () => {
const mockAvailability = {
"variant-1": { availability: 10 },
"variant-2": { availability: 5 },
"variant-3": { availability: 20 },
}
;(getTotalVariantAvailability as jest.Mock).mockResolvedValueOnce(
mockAvailability
)
await wrapVariantsWithTotalInventoryQuantity(
req as MedusaRequest,
variants
)
expect(variants[0].inventory_quantity).toBe(10)
expect(variants[1].inventory_quantity).toBe(5)
expect(variants[2].inventory_quantity).toBeUndefined()
})
})
describe("wrapVariantsWithInventoryQuantityForSalesChannel", () => {
beforeEach(() => {
req = {
scope: {
resolve: jest.fn().mockReturnValue(mockQuery),
},
publishable_key_context: {
sales_channel_ids: ["sc-1"],
},
validatedQuery: {},
}
})
it("should throw an error when multiple sales channels are available and no single one is specified", async () => {
req.publishable_key_context.sales_channel_ids = ["sc-1", "sc-2"]
req.validatedQuery = { sales_channel_id: ["sc-1", "sc-2"] }
await expect(
wrapVariantsWithInventoryQuantityForSalesChannel(
req as MedusaStoreRequest<unknown>,
variants
)
).rejects.toThrow(MedusaError)
})
it("should use sales channel from query when single channel is specified", async () => {
req.validatedQuery = { sales_channel_id: ["sc-2"] }
req.publishable_key_context = {
key: "test-key",
sales_channel_ids: ["sc-1", "sc-2"],
}
const mockAvailability = {
"variant-1": { availability: 7 },
"variant-2": { availability: 3 },
}
;(getVariantAvailability as jest.Mock).mockResolvedValueOnce(
mockAvailability
)
await wrapVariantsWithInventoryQuantityForSalesChannel(
req as MedusaStoreRequest<unknown>,
variants
)
expect(getVariantAvailability).toHaveBeenCalledWith(mockQuery, {
variant_ids: ["variant-1", "variant-2", "variant-3"],
sales_channel_id: "sc-2",
})
})
it("should use sales channel from publishable key when single channel is available", async () => {
const mockAvailability = {
"variant-1": { availability: 12 },
"variant-2": { availability: 8 },
}
;(getVariantAvailability as jest.Mock).mockResolvedValueOnce(
mockAvailability
)
await wrapVariantsWithInventoryQuantityForSalesChannel(
req as MedusaStoreRequest<unknown>,
variants
)
expect(getVariantAvailability).toHaveBeenCalledWith(mockQuery, {
variant_ids: ["variant-1", "variant-2", "variant-3"],
sales_channel_id: "sc-1",
})
})
it("should handle non-array sales_channel_id in query", async () => {
req.validatedQuery = { sales_channel_id: "sc-2" }
const originalPublishableKeyContext = deepCopy(
req.publishable_key_context
)
req.publishable_key_context = {
key: "test-key",
sales_channel_ids: ["sc-1", "sc-2"],
}
const mockAvailability = {
"variant-1": { availability: 7 },
"variant-2": { availability: 3 },
}
;(getVariantAvailability as jest.Mock).mockResolvedValueOnce(
mockAvailability
)
await wrapVariantsWithInventoryQuantityForSalesChannel(
req as MedusaStoreRequest<unknown>,
variants
)
expect(getVariantAvailability).toHaveBeenCalledWith(mockQuery, {
variant_ids: ["variant-1", "variant-2", "variant-3"],
sales_channel_id: "sc-2",
})
req.publishable_key_context = originalPublishableKeyContext
})
it("should update inventory_quantity for variants with manage_inventory=true", async () => {
const mockAvailability = {
"variant-1": { availability: 15 },
"variant-2": { availability: 9 },
"variant-3": { availability: 25 },
}
;(getVariantAvailability as jest.Mock).mockResolvedValueOnce(
mockAvailability
)
await wrapVariantsWithInventoryQuantityForSalesChannel(
req as MedusaStoreRequest<unknown>,
variants
)
expect(variants[0].inventory_quantity).toBe(15)
expect(variants[1].inventory_quantity).toBe(9)
expect(variants[2].inventory_quantity).toBeUndefined()
})
it("should not call getVariantAvailability when variants array is empty", async () => {
await wrapVariantsWithInventoryQuantityForSalesChannel(
req as MedusaStoreRequest<unknown>,
[]
)
expect(getVariantAvailability).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,48 +2,65 @@ import { MedusaStoreRequest } from "@medusajs/framework/http"
import { arrayDifference, MedusaError } from "@medusajs/framework/utils"
import { NextFunction } from "express"
/**
* Transforms and validates the sales channel ids
* @param req
* @returns The transformed and validated sales channel ids
*/
export function transformAndValidateSalesChannelIds(
req: MedusaStoreRequest
): string[] {
const { sales_channel_ids: idsFromPublishableKey = [] } =
req.publishable_key_context
let { sales_channel_id: idsFromRequest = [] } = req.validatedQuery as {
sales_channel_id: string | string[]
}
idsFromRequest = Array.isArray(idsFromRequest)
? idsFromRequest
: [idsFromRequest]
// If all sales channel ids are not in the publishable key, we throw an error
if (idsFromRequest.length) {
const uniqueInParams = arrayDifference(
idsFromRequest,
idsFromPublishableKey
)
if (uniqueInParams.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested sales channel is not part of the publishable key`
)
}
return idsFromRequest
}
if (idsFromPublishableKey?.length) {
return idsFromPublishableKey
}
return []
}
// Selection of sales channels happens in the following priority:
// - If a publishable API key is passed, we take the sales channels attached to it and filter them down based on the query params
// - If a sales channel id is passed through query params, we use that
// - If not, we use the default sales channel for the store
export function filterByValidSalesChannels() {
return async (req: MedusaStoreRequest, _, next: NextFunction) => {
const idsFromRequest = req.filterableFields.sales_channel_id
const { sales_channel_ids: idsFromPublishableKey = [] } =
req.publishable_key_context
const salesChannelIds = transformAndValidateSalesChannelIds(req)
// If all sales channel ids are not in the publishable key, we throw an error
if (Array.isArray(idsFromRequest) && idsFromRequest.length) {
const uniqueInParams = arrayDifference(
idsFromRequest,
idsFromPublishableKey
)
if (uniqueInParams.length) {
return next(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested sales channel is not part of the publishable key`
)
)
}
req.filterableFields.sales_channel_id = idsFromRequest
return next()
}
if (idsFromPublishableKey?.length) {
req.filterableFields.sales_channel_id = idsFromPublishableKey
return next()
}
return next(
new MedusaError(
if (!salesChannelIds.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Publishable key needs to have a sales channel configured`
)
)
}
req.filterableFields.sales_channel_id = salesChannelIds
next()
}
}

View File

@@ -5,6 +5,7 @@ import {
MedusaError,
} from "@medusajs/framework/utils"
import { MedusaRequest, MedusaStoreRequest } from "@medusajs/framework/http"
import { transformAndValidateSalesChannelIds } from "./filter-by-valid-sales-channels"
export const wrapVariantsWithTotalInventoryQuantity = async (
req: MedusaRequest,
@@ -28,25 +29,21 @@ export const wrapVariantsWithInventoryQuantityForSalesChannel = async (
req: MedusaStoreRequest<unknown>,
variants: VariantInput[]
) => {
const salesChannelId = req.filterableFields.sales_channel_id as
| string
| string[]
const { sales_channel_ids: idsFromPublishableKey = [] } =
req.publishable_key_context
const salesChannelIds = transformAndValidateSalesChannelIds(req)
let channelToUse: string | undefined
if (salesChannelId && !Array.isArray(salesChannelId)) {
channelToUse = salesChannelId
}
const publishableApiKeySalesChannelIds =
req.publishable_key_context.sales_channel_ids ?? []
if (idsFromPublishableKey.length === 1) {
channelToUse = idsFromPublishableKey[0]
}
let channelsToUse: string
if (!channelToUse) {
if (publishableApiKeySalesChannelIds.length === 1) {
channelsToUse = publishableApiKeySalesChannelIds[0]
} else if (salesChannelIds.length === 1) {
channelsToUse = salesChannelIds[0]
} else {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Inventory availability cannot be calculated in the given context. Either provide a sales channel id or configure a single sales channel in the publishable key`
`Inventory availability cannot be calculated in the given context. Either provide a single sales channel id or configure a single sales channel in the publishable key`
)
}
@@ -60,7 +57,7 @@ export const wrapVariantsWithInventoryQuantityForSalesChannel = async (
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const availability = await getVariantAvailability(query, {
variant_ids: variantIds,
sales_channel_id: channelToUse,
sales_channel_id: channelsToUse,
})
wrapVariants(variants, availability)