diff --git a/.changeset/proud-turkeys-poke.md b/.changeset/proud-turkeys-poke.md new file mode 100644 index 0000000000..14c6f0dc2b --- /dev/null +++ b/.changeset/proud-turkeys-poke.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): fetching a product without a category with categories filed passed diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 1b4b160f26..ebecd4cb8b 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -787,8 +787,7 @@ medusaIntegrationTestRunner({ }) // TODO: This doesn't work currently, but worked in v1 - it.skip("returns a list of ordered products by variants title DESC", async () => { - }) + it.skip("returns a list of ordered products by variants title DESC", async () => {}) it("returns a list of ordered products by variant title ASC", async () => { const response = await api.get( @@ -1840,6 +1839,7 @@ medusaIntegrationTestRunner({ }) describe("GET /store/products/:id", () => { + let defaultSalesChannel beforeEach(async () => { ;[product, [variant]] = await createProducts({ title: "test product 1", @@ -1868,7 +1868,7 @@ medusaIntegrationTestRunner({ ], }) - const defaultSalesChannel = await createSalesChannel( + defaultSalesChannel = await createSalesChannel( { name: "default sales channel" }, [product.id] ) @@ -1928,6 +1928,184 @@ medusaIntegrationTestRunner({ ) }) + it("should retrieve product withhout category if the categories field is passed", async () => { + const [product] = await createProducts({ + title: "test category prod", + status: ProductStatus.PUBLISHED, + options: [{ title: "size", values: ["large"] }], + variants: [ + { + title: "test category variant", + options: { size: "large" }, + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + ], + }) + + await api.post( + `/admin/sales-channels/${defaultSalesChannel.id}/products`, + { add: [product.id] }, + adminHeaders + ) + + const response = await api.get( + `/store/products/${product.id}?fields=*categories`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product.id, + categories: [], + }) + ) + }) + + it("should retrieve product with category", async () => { + const [product] = await createProducts({ + title: "test category prod", + status: ProductStatus.PUBLISHED, + options: [{ title: "size", values: ["large"] }], + variants: [ + { + title: "test category variant", + options: { size: "large" }, + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + ], + }) + + const category = await createCategory( + { name: "test", is_internal: false, is_active: true }, + [product.id] + ) + + await api.post( + `/admin/sales-channels/${defaultSalesChannel.id}/products`, + { add: [product.id] }, + adminHeaders + ) + + const response = await api.get( + `/store/products/${product.id}?fields=*categories`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product.id, + categories: [expect.objectContaining({ id: category.id })], + }) + ) + }) + + it("should return product without internal category", async () => { + const [product] = await createProducts({ + title: "test category prod", + status: ProductStatus.PUBLISHED, + options: [{ title: "size", values: ["large"] }], + variants: [ + { + title: "test category variant", + options: { size: "large" }, + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + ], + }) + + const category = await createCategory( + { name: "test", is_internal: true, is_active: true }, + [product.id] + ) + + await api.post( + `/admin/sales-channels/${defaultSalesChannel.id}/products`, + { add: [product.id] }, + adminHeaders + ) + + const response = await api.get( + `/store/products/${product.id}?fields=*categories`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product.id, + categories: [], + }) + ) + }) + + it("should return product without internal category (multicategory example)", async () => { + const [product] = await createProducts({ + title: "test category prod", + status: ProductStatus.PUBLISHED, + options: [{ title: "size", values: ["large"] }], + variants: [ + { + title: "test category variant", + options: { size: "large" }, + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + ], + }) + + const categoryInternal = await createCategory( + { name: "test", is_internal: true, is_active: true }, + [product.id] + ) + + const categoryPublic = await createCategory( + { name: "test_public", is_internal: false, is_active: true }, + [product.id] + ) + + await api.post( + `/admin/sales-channels/${defaultSalesChannel.id}/products`, + { add: [product.id] }, + adminHeaders + ) + + const response = await api.get( + `/store/products/${product.id}?fields=*categories`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.categories.length).toEqual(1) + + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product.id, + categories: [expect.objectContaining({ id: categoryPublic.id })], + }) + ) + }) + // TODO: There are 2 problems that need to be solved to enable this test // 1. When adding product to another category, the product is being removed from earlier assigned categories // 2. MikroORM seems to be doing a join strategy to load relationships, we need to send a separate query to fetch relationships diff --git a/packages/medusa/src/api/store/products/[id]/route.ts b/packages/medusa/src/api/store/products/[id]/route.ts index 2fc3bc314e..4cf22bc7b2 100644 --- a/packages/medusa/src/api/store/products/[id]/route.ts +++ b/packages/medusa/src/api/store/products/[id]/route.ts @@ -2,6 +2,7 @@ import { isPresent, MedusaError } from "@medusajs/framework/utils" import { MedusaResponse } from "@medusajs/framework/http" import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares" import { + filterOutInternalProductCategories, refetchProduct, RequestWithContext, wrapProductsWithTaxPrices, @@ -33,6 +34,14 @@ export const GET = async ( } } + const includesCategoriesField = req.queryConfig.fields.some((field) => + field.startsWith("categories") + ) + + if (!req.queryConfig.fields.includes("categories.is_internal")) { + req.queryConfig.fields.push("categories.is_internal") + } + const product = await refetchProduct( filters, req.scope, @@ -53,6 +62,10 @@ export const GET = async ( ) } + if (includesCategoriesField) { + filterOutInternalProductCategories([product]) + } + await wrapProductsWithTaxPrices(req, [product]) res.json({ product }) } diff --git a/packages/medusa/src/api/store/products/helpers.ts b/packages/medusa/src/api/store/products/helpers.ts index 8119991d5a..c2f2dbd9f8 100644 --- a/packages/medusa/src/api/store/products/helpers.ts +++ b/packages/medusa/src/api/store/products/helpers.ts @@ -28,6 +28,22 @@ export const refetchProduct = async ( return await refetchEntity("product", idOrFilter, scope, fields) } +export const filterOutInternalProductCategories = ( + products: HttpTypes.StoreProduct[] +) => { + return products.forEach((product: HttpTypes.StoreProduct) => { + if (!product.categories) { + return + } + + product.categories = product.categories.filter( + (category) => + !(category as HttpTypes.StoreProductCategory & { is_internal: boolean }) + .is_internal + ) + }) +} + export const wrapProductsWithTaxPrices = async ( req: RequestWithContext, products: HttpTypes.StoreProduct[] diff --git a/packages/medusa/src/api/store/products/middlewares.ts b/packages/medusa/src/api/store/products/middlewares.ts index dba652487f..a7d15f1530 100644 --- a/packages/medusa/src/api/store/products/middlewares.ts +++ b/packages/medusa/src/api/store/products/middlewares.ts @@ -93,13 +93,6 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ }), applyDefaultFilters({ status: ProductStatus.PUBLISHED, - categories: (_filters, fields: string[]) => { - if (!fields.some((field) => field.startsWith("categories"))) { - return - } - - return { is_internal: false, is_active: true } - }, }), normalizeDataForContext(), setPricingContext(), diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 0ad9b57395..2af892b68a 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -225,6 +225,7 @@ export default class ProductModuleService this.getProductFindConfig_(config), sharedContext ) + const serializedProducts = await this.baseRepository_.serialize< ProductTypes.ProductDTO[] >(products)