fix(medusa): fetching a product without a category with categories filed passed (#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
This commit is contained in:
Frane Polić
2025-08-26 18:35:41 +02:00
committed by GitHub
parent e413cfefc2
commit 71818e43cb
6 changed files with 216 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): fetching a product without a category with categories filed passed

View File

@@ -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

View File

@@ -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 })
}

View File

@@ -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 <T>(
req: RequestWithContext<T>,
products: HttpTypes.StoreProduct[]

View File

@@ -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(),

View File

@@ -225,6 +225,7 @@ export default class ProductModuleService
this.getProductFindConfig_(config),
sharedContext
)
const serializedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)