From 944051a951eda7d4113293dd03c00bfb63074a29 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Fri, 21 Jun 2024 10:42:09 +0200 Subject: [PATCH] fix: Allow filtering products by variant options in store (#7784) --- .../api/__tests__/store/products.js | 493 ------------------ .../__tests__/product/store/product.spec.ts | 165 +++++- packages/core/types/src/product/common.ts | 6 + .../src/api/store/products/validators.ts | 12 +- .../medusa/src/api/utils/validate-query.ts | 31 +- .../product-module-service/products.spec.ts | 47 +- 6 files changed, 246 insertions(+), 508 deletions(-) diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index 4bfbf6c3f0..c0e5d5428a 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -51,499 +51,6 @@ describe("/store/products", () => { medusaProcess.kill() }) - describe("GET /store/products", () => { - beforeEach(async () => { - const defaultSalesChannel = await simpleSalesChannelFactory( - dbConnection, - { - id: "sales-channel", - is_default: true, - } - ) - await productSeeder(dbConnection, defaultSalesChannel) - await adminSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("returns a list of ordered products by id ASC", async () => { - const api = useApi() - - const response = await api.get("/store/products?order=id") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(5) - expect(response.data.products[0].id).toEqual(giftCardId) - expect(response.data.products[1].id).toEqual(testProductId) - expect(response.data.products[2].id).toEqual(testProductId1) - expect(response.data.products[3].id).toEqual(testProductFilteringId1) - expect(response.data.products[4].id).toEqual(testProductFilteringId2) - }) - - it("returns a list of ordered products by id DESC", async () => { - const api = useApi() - - const response = await api.get("/store/products?order=-id") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(5) - expect(response.data.products[0].id).toEqual(testProductFilteringId2) - expect(response.data.products[1].id).toEqual(testProductFilteringId1) - expect(response.data.products[2].id).toEqual(testProductId1) - expect(response.data.products[3].id).toEqual(testProductId) - expect(response.data.products[4].id).toEqual(giftCardId) - }) - - it("returns a list of ordered products by variants title DESC", async () => { - const api = useApi() - - const response = await api.get("/store/products?order=-variants.title") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(5) - - const testProductIndex = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId) - ) - const testProduct1Index = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId1) - ) - - // Since they have the same variant titles for rank 2, the order is not guaranteed - expect([3, 4]).toContain(testProductIndex) - expect([3, 4]).toContain(testProduct1Index) - }) - - it("returns a list of ordered products by variants title ASC", async () => { - const api = useApi() - - const response = await api.get("/store/products?order=variants.title") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(5) - - const testProductIndex = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId) - ) - const testProduct1Index = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId1) - ) - - expect(testProductIndex).toBe(0) - expect(testProduct1Index).toBe(1) - }) - - it("returns a list of ordered products by variants prices DESC", async () => { - const api = useApi() - - await simpleProductFactory(dbConnection, { - id: testProductId2, - status: "published", - variants: [ - { - id: "test_variant_5", - prices: [ - { - currency: "usd", - amount: 200, - }, - ], - }, - ], - }) - - let response = await api.get( - "/store/products?order=-variants.prices.amount" - ) - - // Update amount to unsure order, same amount will add randomness in the result with the same amounts - const productToUpdate = response.data.products.find( - (p) => p.id === testProductId - ) - const priceToUpdate = productToUpdate.variants[0].prices[0] - const priceData = { - id: priceToUpdate.id, - currency_code: priceToUpdate.currency_code, - amount: 120, - } - - await api.post( - `/admin/products/${testProductId}/variants/${productToUpdate.variants[0].id}`, - { prices: [priceData] }, - adminHeaders - ) - - response = await api.get("/store/products?order=-variants.prices.amount") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(6) - - const testProductIndex = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId) - ) - const testProduct1Index = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId1) - ) - const testProduct2Index = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId2) - ) - - expect(testProduct2Index).toBe(3) // 200 - expect(testProductIndex).toBe(4) // 120 - expect(testProduct1Index).toBe(5) // 100 - }) - - it("returns a list of ordered products by variants prices ASC", async () => { - const api = useApi() - - await simpleProductFactory(dbConnection, { - id: testProductId2, - status: "published", - variants: [ - { - id: "test_variant_5", - prices: [ - { - currency: "usd", - amount: 200, - }, - ], - }, - ], - }) - - let response = await api.get( - "/store/products?order=variants.prices.amount" - ) - - // Update amount to unsure order, same amount will add randomness in the result with the same amounts - const productToUpdate = response.data.products.find( - (p) => p.id === testProductId1 - ) - const priceToUpdate = productToUpdate.variants[0].prices[0] - const priceData = { - id: priceToUpdate.id, - currency_code: priceToUpdate.currency_code, - amount: 120, - } - - await api.post( - `/admin/products/${testProductId1}/variants/${productToUpdate.variants[0].id}`, - { prices: [priceData] }, - adminHeaders - ) - - response = await api.get("/store/products?order=variants.prices.amount") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(6) - - const testProductIndex = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId) - ) - const testProduct1Index = response.data.products.indexOf( - response.data.products.find((p) => p.id === testProductId1) - ) - const testProduct2Index = response.data.products.indexOf( - response.data.products.find((p) => p.id === "test-product2") - ) - - expect(testProductIndex).toBe(0) // 100 - expect(testProduct1Index).toBe(1) // 120 - expect(testProduct2Index).toBe(2) // 200 - }) - - it("products contain only fields defined with `fields` param", async () => { - const api = useApi() - - const response = await api.get("/store/products?fields=handle") - - expect(response.status).toEqual(200) - - expect(Object.keys(response.data.products[0])).toHaveLength(10) - expect(Object.keys(response.data.products[0])).toEqual( - expect.arrayContaining([ - "id", - "created_at", - - // fields - "handle", - // relations - "variants", - "options", - "images", - "tags", - "collection", - "type", - "profiles", - ]) - ) - }) - - it("returns a list of ordered products by id ASC and filtered with free text search", async () => { - const api = useApi() - - const response = await api.get("/store/products?q=filtering&order=id") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(2) - - expect(response.data.products).toEqual([ - expect.objectContaining({ - id: testProductFilteringId1, - }), - expect.objectContaining({ - id: testProductFilteringId2, - }), - ]) - }) - - it("returns a list of ordered products by id DESC and filtered with free text search", async () => { - const api = useApi() - - const response = await api.get("/store/products?q=filtering&order=-id") - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(2) - - expect(response.data.products).toEqual([ - expect.objectContaining({ - id: testProductFilteringId2, - }), - expect.objectContaining({ - id: testProductFilteringId1, - }), - ]) - }) - - it("returns a list of products in collection", async () => { - const api = useApi() - - const notExpected = [ - expect.objectContaining({ collection_id: "test-collection" }), - expect.objectContaining({ collection_id: "test-collection1" }), - ] - - const response = await api - .get("/store/products?collection_id[]=test-collection2") - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProductFilteringId2, - collection_id: "test-collection2", - }), - ]) - ) - - for (const notExpect of notExpected) { - expect(response.data.products).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - }) - - it("returns a list of products with a given tag", async () => { - const api = useApi() - - const notExpected = [expect.objectContaining({ id: "tag4" })] - - const response = await api - .get("/store/products?tags[]=tag3") - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProductFilteringId1, - collection_id: "test-collection1", - }), - ]) - ) - - for (const notExpect of notExpected) { - expect(response.data.products).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - }) - - it("returns gift card product", async () => { - const api = useApi() - - const response = await api - .get("/store/products?is_giftcard=true") - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products.length).toEqual(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: giftCardId, - is_giftcard: true, - }), - ]) - ) - }) - - it("returns non gift card products", async () => { - const api = useApi() - - const response = await api - .get("/store/products?is_giftcard=false") - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - - expect(response.data.products).toEqual( - expect.not.arrayContaining([ - expect.objectContaining({ is_giftcard: true }), - ]) - ) - }) - - it("returns product with tag", async () => { - const api = useApi() - - const notExpected = [expect.objectContaining({ id: "tag4" })] - - const response = await api - .get("/store/products?tags[]=tag3") - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProductFilteringId1, - collection_id: "test-collection1", - }), - ]) - ) - - for (const notExpect of notExpected) { - expect(response.data.products).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - }) - - it("returns a list of products in with a given handle", async () => { - const api = useApi() - - const notExpected = [ - expect.objectContaining({ handle: testProductFilteringId1 }), - ] - - const response = await api - .get("/store/products?handle=test-product_filtering_2") - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProductFilteringId2, - handle: testProductFilteringId2, - }), - ]) - ) - - for (const notExpect of notExpected) { - expect(response.data.products).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - }) - - it("works when filtering by type_id", async () => { - const api = useApi() - - const response = await api.get( - `/store/products?type_id[]=test-type&fields=id,title,type_id` - ) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(5) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type_id: "test-type", - }), - ]) - ) - }) - - it("returns only published products", async () => { - const api = useApi() - - const notExpected = [ - expect.objectContaining({ status: "proposed" }), - expect.objectContaining({ status: "draft" }), - expect.objectContaining({ status: "rejected" }), - ] - - const response = await api.get("/store/products").catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products.length).toEqual(5) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProductId1, - collection_id: "test-collection", - }), - expect.objectContaining({ - id: testProductId, - collection_id: "test-collection", - }), - expect.objectContaining({ - id: testProductFilteringId2, - collection_id: "test-collection2", - }), - expect.objectContaining({ - id: testProductFilteringId1, - collection_id: "test-collection1", - }), - expect.objectContaining({ - id: giftCardId, - }), - ]) - ) - - for (const notExpect of notExpected) { - expect(response.data.products).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - }) - }) - describe("list params", () => { beforeEach(async () => { await productSeeder(dbConnection) diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 303fc4ca54..de3aab92b7 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -18,6 +18,7 @@ medusaIntegrationTestRunner({ testSuite: ({ dbConnection, api, getContainer }) => { let store let appContainer + let collection let product let product1 let product2 @@ -487,13 +488,31 @@ medusaIntegrationTestRunner({ adminHeaders ) ).data.inventory_item + + collection = ( + await api.post( + "/admin/collections", + { title: "base-collection" }, + adminHeaders + ) + ).data.collection ;[product, [variant]] = await createProducts({ title: "test product 1", + collection_id: collection.id, status: ProductStatus.PUBLISHED, + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["green"] }, + ], + tags: [{ value: "tag1" }], variants: [ { title: "test variant 1", manage_inventory: true, + options: { + size: "large", + color: "green", + }, inventory_items: [ { inventory_item_id: inventoryItem1.id, @@ -511,8 +530,20 @@ medusaIntegrationTestRunner({ ;[product2, [variant2]] = await createProducts({ title: "test product 2 uniquely", status: ProductStatus.PUBLISHED, + options: [ + { title: "size", values: ["large", "small"] }, + { title: "material", values: ["cotton", "polyester"] }, + ], variants: [ - { title: "test variant 2", manage_inventory: false, prices: [] }, + { + title: "test variant 2", + options: { + size: "large", + material: "cotton", + }, + manage_inventory: false, + prices: [], + }, ], }) ;[product3, [variant3]] = await createProducts({ @@ -618,6 +649,138 @@ medusaIntegrationTestRunner({ ]) }) + it("returns a list of ordered products by id ASC", async () => { + const response = await api.get("/store/products?order=id") + expect(response.status).toEqual(200) + expect(response.data.products).toEqual( + [product.id, product2.id, product3.id] + .sort((p1, p2) => p1.localeCompare(p2)) + .map((id) => expect.objectContaining({ id })) + ) + }) + + it("returns a list of ordered products by id DESC", async () => { + const response = await api.get("/store/products?order=-id") + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual( + [product.id, product2.id, product3.id] + .sort((p1, p2) => p2.localeCompare(p1)) + .map((id) => expect.objectContaining({ id })) + ) + }) + + // TODO: This doesn't work currently, but worked in v1 + it.skip("returns a list of ordered products by variants title DESC", async () => { + const response = await api.get("/store/products?order=-variants.title") + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ id: product3.id }), + expect.objectContaining({ id: product2.id }), + expect.objectContaining({ id: product.id }), + ]) + }) + + // TODO: This doesn't work currently, but worked in v1 + it.skip("returns a list of ordered products by variants title ASC", async () => { + const response = await api.get("/store/products?order=variants.title") + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ id: product3.id }), + expect.objectContaining({ id: product2.id }), + expect.objectContaining({ id: product.id }), + ]) + }) + + // TODO: This doesn't work currently, but worked in v1 + it.skip("returns a list of ordered products by variants prices DESC", async () => { + let response = await api.get( + "/store/products?order=-variants.prices.amount" + ) + }) + + // TODO: This doesn't work currently, but worked in v1 + it.skip("returns a list of ordered products by variants prices ASC", async () => {}) + + // BREAKING: It seems `id` and `created_at` is always returned, even if not in the fields params + it("products contain only fields defined with `fields` param", async () => { + const response = await api.get("/store/products?fields=handle") + expect(response.status).toEqual(200) + expect(Object.keys(response.data.products[0])).toEqual([ + "handle", + "created_at", + "id", + ]) + }) + + it("returns a list of products in collection", async () => { + const response = await api.get( + `/store/products?collection_id[]=${collection.id}` + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ id: product.id }), + ]) + }) + + it("returns a list of products with a given tag", async () => { + const response = await api.get( + `/store/products?tags[]=${product.tags[0].id}` + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ id: product.id }), + ]) + }) + + // TODO: Not implemented yet + it.skip("returns gift card product", async () => { + const response = await api + .get("/store/products?is_giftcard=true") + .catch((err) => { + console.log(err) + }) + }) + + // TODO: Not implemented yet + it.skip("returns non gift card products", async () => { + const response = await api + .get("/store/products?is_giftcard=false") + .catch((err) => { + console.log(err) + }) + }) + + it("returns a list of products in with a given handle", async () => { + const response = await api.get( + `/store/products?handle=${product.handle}` + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ id: product.id }), + ]) + }) + + it("returns a list of products filtered by variant options", async () => { + const response = await api.get( + `/store/products?variants.options[option_id]=${product.options[1].id}&variants.options[value]=large` + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ id: product.id }), + ]) + }) + describe("with publishable keys", () => { let salesChannel1 let salesChannel2 diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 16a163c3f9..d71c9999bf 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -702,6 +702,12 @@ export interface FilterableProductProps */ value?: string[] } + /** + * Filters on a product's variant properties. + */ + variants?: { + options: { value: string; option_id: string } + } /** * Filter a product by the ID of the associated type */ diff --git a/packages/medusa/src/api/store/products/validators.ts b/packages/medusa/src/api/store/products/validators.ts index bcf9c1f1c8..c3d8385362 100644 --- a/packages/medusa/src/api/store/products/validators.ts +++ b/packages/medusa/src/api/store/products/validators.ts @@ -22,6 +22,7 @@ export const StoreGetProductVariantsParams = createFindParams({ q: z.string().optional(), id: z.union([z.string(), z.array(z.string())]).optional(), status: ProductStatusEnum.array().optional(), + options: z.object({ value: z.string(), option_id: z.string() }).optional(), created_at: createOperatorMap().optional(), updated_at: createOperatorMap().optional(), deleted_at: createOperatorMap().optional(), @@ -39,7 +40,16 @@ export const StoreGetProductsParams = createFindParams({ .object({ region_id: z.string().optional(), currency_code: z.string().optional(), - variants: StoreGetProductVariantsParams.optional(), + variants: z + .object({ + status: ProductStatusEnum.array().optional(), + options: z + .object({ value: z.string(), option_id: z.string() }) + .optional(), + $and: z.lazy(() => StoreGetProductsParams.array()).optional(), + $or: z.lazy(() => StoreGetProductsParams.array()).optional(), + }) + .optional(), $and: z.lazy(() => StoreGetProductsParams.array()).optional(), $or: z.lazy(() => StoreGetProductsParams.array()).optional(), }) diff --git a/packages/medusa/src/api/utils/validate-query.ts b/packages/medusa/src/api/utils/validate-query.ts index dcfa93c4f4..e9fedaa0e5 100644 --- a/packages/medusa/src/api/utils/validate-query.ts +++ b/packages/medusa/src/api/utils/validate-query.ts @@ -9,18 +9,41 @@ import { prepareRetrieveQuery, } from "../../utils/get-query-config" import { zodValidator } from "./zod-helper" +import { MedusaError } from "@medusajs/utils" /** * Normalize an input query, especially from array like query params to an array type * e.g: /admin/orders/?fields[]=id,status,cart_id becomes { fields: ["id", "status", "cart_id"] } + * + * We only support up to 2 levels of depth for query params in order to have a somewhat readable query param, and limit possible performance issues */ const normalizeQuery = (req: MedusaRequest) => { return Object.entries(req.query).reduce((acc, [key, val]) => { - if (Array.isArray(val) && val.length === 1) { - acc[key] = (val as string[])[0].split(",") - } else { - acc[key] = val + let normalizedValue = val + if (Array.isArray(val) && val.length === 1 && typeof val[0] === "string") { + normalizedValue = val[0].split(",") } + + if (key.includes(".")) { + const [parent, child, ...others] = key.split(".") + if (others.length > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + `Key accessor more than 2 levels deep: ${key}` + ) + } + + if (!acc[parent]) { + acc[parent] = {} + } + acc[parent] = { + ...acc[parent], + [child]: normalizedValue, + } + } else { + acc[key] = normalizedValue + } + return acc }, {}) } diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index 7c7d198834..c81789665a 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -803,6 +803,8 @@ moduleIntegrationTestRunner({ }) describe("list", function () { + let productOneData + let productTwoData beforeEach(async () => { const collections = await createCollections( MikroOrmWrapper.forkManager(), @@ -812,16 +814,20 @@ moduleIntegrationTestRunner({ productCollectionOne = collections[0] productCollectionTwo = collections[1] - const productOneData = buildProductAndRelationsData({ - collection_id: productCollectionOne.id, - }) + const resp = await service.createProducts([ + buildProductAndRelationsData({ + collection_id: productCollectionOne.id, + options: [{ title: "size", values: ["large", "small"] }], + variants: [{ title: "variant 1", options: { size: "small" } }], + }), + buildProductAndRelationsData({ + collection_id: productCollectionTwo.id, + tags: [], + }), + ]) - const productTwoData = buildProductAndRelationsData({ - collection_id: productCollectionTwo.id, - tags: [], - }) - - await service.createProducts([productOneData, productTwoData]) + productOneData = resp[0] + productTwoData = resp[1] }) it("should return a list of products scoped by collection id", async () => { @@ -843,6 +849,29 @@ moduleIntegrationTestRunner({ ]) }) + it("should return a list of products scoped by variant options", async () => { + const productsWithVariants = await service.listProducts( + { + variants: { + options: { + option_id: productOneData.options[0].id, + value: "small", + }, + }, + }, + { + relations: ["variants", "variants.options"], + } + ) + + expect(productsWithVariants).toHaveLength(1) + expect(productsWithVariants).toEqual([ + expect.objectContaining({ + id: productOneData.id, + }), + ]) + }) + it("should return empty array when querying for a collection that doesnt exist", async () => { const products = await service.listProducts( {