Skip to content

Commit 71818e4

Browse files
authored
fix(medusa): fetching a product without a category with categories filed passed (medusajs#13020)
**What** - adding `categories` to `fields` param would return 404 for an existing product if the product was not associated with a category --- CLOSES CMRC-1046
1 parent e413cfe commit 71818e4

File tree

6 files changed

+216
-10
lines changed

6 files changed

+216
-10
lines changed

.changeset/proud-turkeys-poke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@medusajs/medusa": patch
3+
---
4+
5+
fix(medusa): fetching a product without a category with categories filed passed

integration-tests/http/__tests__/product/store/product.spec.ts

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -787,8 +787,7 @@ medusaIntegrationTestRunner({
787787
})
788788

789789
// TODO: This doesn't work currently, but worked in v1
790-
it.skip("returns a list of ordered products by variants title DESC", async () => {
791-
})
790+
it.skip("returns a list of ordered products by variants title DESC", async () => {})
792791

793792
it("returns a list of ordered products by variant title ASC", async () => {
794793
const response = await api.get(
@@ -1840,6 +1839,7 @@ medusaIntegrationTestRunner({
18401839
})
18411840

18421841
describe("GET /store/products/:id", () => {
1842+
let defaultSalesChannel
18431843
beforeEach(async () => {
18441844
;[product, [variant]] = await createProducts({
18451845
title: "test product 1",
@@ -1868,7 +1868,7 @@ medusaIntegrationTestRunner({
18681868
],
18691869
})
18701870

1871-
const defaultSalesChannel = await createSalesChannel(
1871+
defaultSalesChannel = await createSalesChannel(
18721872
{ name: "default sales channel" },
18731873
[product.id]
18741874
)
@@ -1928,6 +1928,184 @@ medusaIntegrationTestRunner({
19281928
)
19291929
})
19301930

1931+
it("should retrieve product withhout category if the categories field is passed", async () => {
1932+
const [product] = await createProducts({
1933+
title: "test category prod",
1934+
status: ProductStatus.PUBLISHED,
1935+
options: [{ title: "size", values: ["large"] }],
1936+
variants: [
1937+
{
1938+
title: "test category variant",
1939+
options: { size: "large" },
1940+
prices: [
1941+
{
1942+
amount: 3000,
1943+
currency_code: "usd",
1944+
},
1945+
],
1946+
},
1947+
],
1948+
})
1949+
1950+
await api.post(
1951+
`/admin/sales-channels/${defaultSalesChannel.id}/products`,
1952+
{ add: [product.id] },
1953+
adminHeaders
1954+
)
1955+
1956+
const response = await api.get(
1957+
`/store/products/${product.id}?fields=*categories`,
1958+
storeHeaders
1959+
)
1960+
1961+
expect(response.status).toEqual(200)
1962+
expect(response.data.product).toEqual(
1963+
expect.objectContaining({
1964+
id: product.id,
1965+
categories: [],
1966+
})
1967+
)
1968+
})
1969+
1970+
it("should retrieve product with category", async () => {
1971+
const [product] = await createProducts({
1972+
title: "test category prod",
1973+
status: ProductStatus.PUBLISHED,
1974+
options: [{ title: "size", values: ["large"] }],
1975+
variants: [
1976+
{
1977+
title: "test category variant",
1978+
options: { size: "large" },
1979+
prices: [
1980+
{
1981+
amount: 3000,
1982+
currency_code: "usd",
1983+
},
1984+
],
1985+
},
1986+
],
1987+
})
1988+
1989+
const category = await createCategory(
1990+
{ name: "test", is_internal: false, is_active: true },
1991+
[product.id]
1992+
)
1993+
1994+
await api.post(
1995+
`/admin/sales-channels/${defaultSalesChannel.id}/products`,
1996+
{ add: [product.id] },
1997+
adminHeaders
1998+
)
1999+
2000+
const response = await api.get(
2001+
`/store/products/${product.id}?fields=*categories`,
2002+
storeHeaders
2003+
)
2004+
2005+
expect(response.status).toEqual(200)
2006+
expect(response.data.product).toEqual(
2007+
expect.objectContaining({
2008+
id: product.id,
2009+
categories: [expect.objectContaining({ id: category.id })],
2010+
})
2011+
)
2012+
})
2013+
2014+
it("should return product without internal category", async () => {
2015+
const [product] = await createProducts({
2016+
title: "test category prod",
2017+
status: ProductStatus.PUBLISHED,
2018+
options: [{ title: "size", values: ["large"] }],
2019+
variants: [
2020+
{
2021+
title: "test category variant",
2022+
options: { size: "large" },
2023+
prices: [
2024+
{
2025+
amount: 3000,
2026+
currency_code: "usd",
2027+
},
2028+
],
2029+
},
2030+
],
2031+
})
2032+
2033+
const category = await createCategory(
2034+
{ name: "test", is_internal: true, is_active: true },
2035+
[product.id]
2036+
)
2037+
2038+
await api.post(
2039+
`/admin/sales-channels/${defaultSalesChannel.id}/products`,
2040+
{ add: [product.id] },
2041+
adminHeaders
2042+
)
2043+
2044+
const response = await api.get(
2045+
`/store/products/${product.id}?fields=*categories`,
2046+
storeHeaders
2047+
)
2048+
2049+
expect(response.status).toEqual(200)
2050+
expect(response.data.product).toEqual(
2051+
expect.objectContaining({
2052+
id: product.id,
2053+
categories: [],
2054+
})
2055+
)
2056+
})
2057+
2058+
it("should return product without internal category (multicategory example)", async () => {
2059+
const [product] = await createProducts({
2060+
title: "test category prod",
2061+
status: ProductStatus.PUBLISHED,
2062+
options: [{ title: "size", values: ["large"] }],
2063+
variants: [
2064+
{
2065+
title: "test category variant",
2066+
options: { size: "large" },
2067+
prices: [
2068+
{
2069+
amount: 3000,
2070+
currency_code: "usd",
2071+
},
2072+
],
2073+
},
2074+
],
2075+
})
2076+
2077+
const categoryInternal = await createCategory(
2078+
{ name: "test", is_internal: true, is_active: true },
2079+
[product.id]
2080+
)
2081+
2082+
const categoryPublic = await createCategory(
2083+
{ name: "test_public", is_internal: false, is_active: true },
2084+
[product.id]
2085+
)
2086+
2087+
await api.post(
2088+
`/admin/sales-channels/${defaultSalesChannel.id}/products`,
2089+
{ add: [product.id] },
2090+
adminHeaders
2091+
)
2092+
2093+
const response = await api.get(
2094+
`/store/products/${product.id}?fields=*categories`,
2095+
storeHeaders
2096+
)
2097+
2098+
expect(response.status).toEqual(200)
2099+
expect(response.data.product.categories.length).toEqual(1)
2100+
2101+
expect(response.data.product).toEqual(
2102+
expect.objectContaining({
2103+
id: product.id,
2104+
categories: [expect.objectContaining({ id: categoryPublic.id })],
2105+
})
2106+
)
2107+
})
2108+
19312109
// TODO: There are 2 problems that need to be solved to enable this test
19322110
// 1. When adding product to another category, the product is being removed from earlier assigned categories
19332111
// 2. MikroORM seems to be doing a join strategy to load relationships, we need to send a separate query to fetch relationships

packages/medusa/src/api/store/products/[id]/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isPresent, MedusaError } from "@medusajs/framework/utils"
22
import { MedusaResponse } from "@medusajs/framework/http"
33
import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares"
44
import {
5+
filterOutInternalProductCategories,
56
refetchProduct,
67
RequestWithContext,
78
wrapProductsWithTaxPrices,
@@ -33,6 +34,14 @@ export const GET = async (
3334
}
3435
}
3536

37+
const includesCategoriesField = req.queryConfig.fields.some((field) =>
38+
field.startsWith("categories")
39+
)
40+
41+
if (!req.queryConfig.fields.includes("categories.is_internal")) {
42+
req.queryConfig.fields.push("categories.is_internal")
43+
}
44+
3645
const product = await refetchProduct(
3746
filters,
3847
req.scope,
@@ -53,6 +62,10 @@ export const GET = async (
5362
)
5463
}
5564

65+
if (includesCategoriesField) {
66+
filterOutInternalProductCategories([product])
67+
}
68+
5669
await wrapProductsWithTaxPrices(req, [product])
5770
res.json({ product })
5871
}

packages/medusa/src/api/store/products/helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ export const refetchProduct = async (
2828
return await refetchEntity("product", idOrFilter, scope, fields)
2929
}
3030

31+
export const filterOutInternalProductCategories = (
32+
products: HttpTypes.StoreProduct[]
33+
) => {
34+
return products.forEach((product: HttpTypes.StoreProduct) => {
35+
if (!product.categories) {
36+
return
37+
}
38+
39+
product.categories = product.categories.filter(
40+
(category) =>
41+
!(category as HttpTypes.StoreProductCategory & { is_internal: boolean })
42+
.is_internal
43+
)
44+
})
45+
}
46+
3147
export const wrapProductsWithTaxPrices = async <T>(
3248
req: RequestWithContext<T>,
3349
products: HttpTypes.StoreProduct[]

packages/medusa/src/api/store/products/middlewares.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,6 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
9393
}),
9494
applyDefaultFilters({
9595
status: ProductStatus.PUBLISHED,
96-
categories: (_filters, fields: string[]) => {
97-
if (!fields.some((field) => field.startsWith("categories"))) {
98-
return
99-
}
100-
101-
return { is_internal: false, is_active: true }
102-
},
10396
}),
10497
normalizeDataForContext(),
10598
setPricingContext(),

packages/modules/product/src/services/product-module-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export default class ProductModuleService
225225
this.getProductFindConfig_(config),
226226
sharedContext
227227
)
228+
228229
const serializedProducts = await this.baseRepository_.serialize<
229230
ProductTypes.ProductDTO[]
230231
>(products)

0 commit comments

Comments
 (0)