diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 36bea60e32..f0928b6332 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -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`, diff --git a/packages/medusa/src/api/utils/middlewares/products/__tests__/filter-by-valid-sales-channels.spec.ts b/packages/medusa/src/api/utils/middlewares/products/__tests__/filter-by-valid-sales-channels.spec.ts new file mode 100644 index 0000000000..536f54dc48 --- /dev/null +++ b/packages/medusa/src/api/utils/middlewares/products/__tests__/filter-by-valid-sales-channels.spec.ts @@ -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 + + 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 + let res: any + let next: NextFunction + let middleware: ReturnType + + 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() + }) + }) +}) diff --git a/packages/medusa/src/api/utils/middlewares/products/__tests__/variant-inventory-quantity.spec.ts b/packages/medusa/src/api/utils/middlewares/products/__tests__/variant-inventory-quantity.spec.ts new file mode 100644 index 0000000000..ac7b6aca52 --- /dev/null +++ b/packages/medusa/src/api/utils/middlewares/products/__tests__/variant-inventory-quantity.spec.ts @@ -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, + 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, + 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, + 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, + 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, + 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, + [] + ) + + expect(getVariantAvailability).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts b/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts index 3a14e53b2d..e672046785 100644 --- a/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts +++ b/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts @@ -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() } } diff --git a/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts b/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts index e6f34cc7ab..46f98e3b01 100644 --- a/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts +++ b/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts @@ -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, 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)