From 585818eaf86f847f4efc16287a427dc9eecf91f2 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Mon, 1 Apr 2024 11:33:52 +0200 Subject: [PATCH] feat: Pricing update and refactor product API tests to not rely on internals (#6892) This PR achieves the following - Remove dependency on internals for seeding the tests (more work left, but major work done) - Adds the workflow for updating variant's price I will do a follow-up PR to further clean up the tests and remove all internal dependencies --- .../api/__tests__/admin/product.js | 1151 +++++++---------- .../src/pricing/steps/update-price-sets.ts | 8 +- .../product/steps/get-variant-pricing-link.ts | 50 + .../workflows/create-product-variants.ts | 33 +- .../src/product/workflows/create-products.ts | 11 +- .../workflows/update-product-variants.ts | 82 +- .../src/api-v2/admin/products/helpers.ts | 4 + .../src/api-v2/admin/products/validators.ts | 3 +- .../src/services/product-module-service.ts | 8 +- packages/types/src/product/common.ts | 2 +- 10 files changed, 658 insertions(+), 694 deletions(-) create mode 100644 packages/core-flows/src/product/steps/get-variant-pricing-link.ts diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 7dbf3ac288..82db61f753 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -11,10 +11,8 @@ const { const { PriceListStatus, PriceListType } = require("@medusajs/types") const { ContainerRegistrationKeys } = require("@medusajs/utils") -let productSeeder = undefined let priceListSeeder = undefined let { - ProductVariant, ProductOptionValue, MoneyAmount, DiscountConditionType, @@ -23,18 +21,17 @@ let { AllocationType, } = {} let { - simpleProductFactory, simpleDiscountFactory, simpleSalesChannelFactory, - simpleRegionFactory, simplePriceListFactory, } = {} jest.setTimeout(50000) -const getProductFixture = () => ({ +const getProductFixture = (overrides) => ({ title: "Test fixture", description: "test-product-description", + status: "draft", images: breaking( () => ["test-image.png", "test-image-2.png"], () => [{ url: "test-image.png" }, { url: "test-image-2.png" }] @@ -43,7 +40,7 @@ const getProductFixture = () => ({ options: breaking( () => [{ title: "size" }, { title: "color" }], () => [ - { title: "size", values: ["large"] }, + { title: "size", values: ["large", "small"] }, { title: "color", values: ["green"] }, ] ), @@ -74,24 +71,33 @@ const getProductFixture = () => ({ ), }, ], + ...(overrides ?? {}), }) medusaIntegrationTestRunner({ env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, testSuite: ({ dbConnection, getContainer, api }) => { - let v2Product + let baseProduct + let proposedProduct + let publishedProduct + let deletedProduct + + let baseCollection + let publishedCollection + + let baseType + + let baseRegion + let pricingService - let productService let scService let remoteLink let container beforeAll(() => { // Note: We have to lazily load everything because there are weird ordering issues when doing `require` of `@medusajs/medusa` - productSeeder = require("../../../helpers/product-seeder") priceListSeeder = require("../../../helpers/price-list-seeder") ;({ - ProductVariant, ProductOptionValue, MoneyAmount, DiscountConditionType, @@ -100,10 +106,8 @@ medusaIntegrationTestRunner({ AllocationType, } = require("@medusajs/medusa")) ;({ - simpleProductFactory, simpleDiscountFactory, simpleSalesChannelFactory, - simpleRegionFactory, simplePriceListFactory, } = require("../../../factories")) }) @@ -112,17 +116,111 @@ medusaIntegrationTestRunner({ container = getContainer() await createAdminUser(dbConnection, adminHeaders, container) - // We want to seed another product for v2 that has pricing correctly wired up for all pricing-related tests. - v2Product = ( - await breaking( - async () => ({}), - async () => - await api.post("/admin/products", getProductFixture(), adminHeaders) + baseCollection = ( + await api.post( + "/admin/collections", + { title: "base-collection" }, + adminHeaders ) - )?.data?.product + ).data.collection + + publishedCollection = ( + await api.post( + "/admin/collections", + { title: "proposed-collection" }, + adminHeaders + ) + ).data.collection + + baseType = await breaking( + async () => {}, + async () => { + return ( + await api.post( + "/admin/product-types", + { value: "test-type" }, + adminHeaders + ) + ).data.product_type + } + ) + + baseRegion = ( + await api.post( + "/admin/regions", + { + name: "Test region", + currency_code: "USD", + ...breaking( + () => ({ + tax_rate: 0, + payment_providers: [], + fulfillment_providers: [], + countries: ["us"], + }), + () => ({}) + ), + }, + adminHeaders + ) + ).data.region + + baseProduct = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Base product", + collection_id: baseCollection.id, + ...breaking( + () => ({ type: { value: "test-type" } }), + () => ({ type_id: baseType.id }) + ), + }), + adminHeaders + ) + ).data.product + + baseType = breaking( + () => baseProduct.type, + () => baseType + ) + + proposedProduct = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Proposed product", + status: "proposed", + tags: [{ value: "new-tag" }], + ...breaking( + () => ({ type: { value: "test-type" } }), + () => ({ type_id: baseType.id }) + ), + }), + adminHeaders + ) + ).data.product + publishedProduct = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Published product", + status: "published", + collection_id: publishedCollection.id, + }), + adminHeaders + ) + ).data.product + deletedProduct = ( + await api.post( + "/admin/products", + getProductFixture({ title: "Deleted product" }), + adminHeaders + ) + ).data.product + await api.delete(`/admin/products/${deletedProduct.id}`, adminHeaders) pricingService = container.resolve(ModuleRegistrationName.PRICING) - productService = container.resolve(ModuleRegistrationName.PRODUCT) scService = container.resolve(ModuleRegistrationName.SALES_CHANNEL) remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) }) @@ -130,7 +228,6 @@ medusaIntegrationTestRunner({ describe("/admin/products", () => { describe("GET /admin/products", () => { beforeEach(async () => { - await productSeeder(dbConnection) await simpleSalesChannelFactory(dbConnection, { name: "Default channel", id: "default-channel", @@ -149,12 +246,12 @@ medusaIntegrationTestRunner({ expect(res.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product", - status: "draft", + id: baseProduct.id, + status: baseProduct.status, }), expect.objectContaining({ - id: "test-product1", - status: "draft", + id: proposedProduct.id, + status: proposedProduct.status, }), ]) ) @@ -171,18 +268,16 @@ medusaIntegrationTestRunner({ expect(res.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product", - status: "draft", + id: baseProduct.id, }), expect.objectContaining({ - id: "test-product1", - status: "draft", + id: proposedProduct.id, }), ]) ) }) - // TODO: In v2 product shouldn't have a direct relationship with price_list right? Should we skip this test in v2? + // TODO: replace factory with API call it.skip("should return prices not in price list for list product endpoint", async () => { await simplePriceListFactory(dbConnection, { prices: [ @@ -224,9 +319,8 @@ medusaIntegrationTestRunner({ status: "proposed", } - // update test-product status to proposed await api - .post("/admin/products/test-product", payload, adminHeaders) + .post(`/admin/products/${baseProduct.id}`, payload, adminHeaders) .catch((err) => { console.log(err) }) @@ -241,7 +335,7 @@ medusaIntegrationTestRunner({ expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product", + id: baseProduct.id, status: "proposed", }), ]) @@ -253,7 +347,7 @@ medusaIntegrationTestRunner({ expect.objectContaining({ status: "draft" }), expect.objectContaining({ status: "rejected" }), expect.objectContaining({ - id: "test-product_filtering_4", + id: baseProduct.id, }), ] @@ -268,11 +362,11 @@ medusaIntegrationTestRunner({ expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product_filtering_1", + id: proposedProduct.id, status: "proposed", }), expect.objectContaining({ - id: "test-product_filtering_2", + id: publishedProduct.id, status: "published", }), ]) @@ -285,25 +379,25 @@ medusaIntegrationTestRunner({ } }) - it("returns a list of products where type_id is test-type", async () => { + it("returns a list of products where type_id is filtered", async () => { const response = await api - .get("/admin/products?type_id[]=test-type", adminHeaders) + .get(`/admin/products?type_id[]=${baseType.id}`, adminHeaders) .catch((err) => { console.log(err) }) expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(5) + expect(response.data.products).toHaveLength(2) expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - type_id: "test-type", + type_id: baseType.id, }), ]) ) }) - // TODO: Enable once pricing and discounts are available + // TODO: Decide how this should be handled in v2 it.skip("returns a list of products filtered by discount condition id", async () => { const resProd = await api.get("/admin/products", adminHeaders) @@ -400,11 +494,11 @@ medusaIntegrationTestRunner({ expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product_filtering_1", + id: proposedProduct.id, status: "proposed", }), expect.objectContaining({ - id: "test-product_filtering_2", + id: publishedProduct.id, status: "published", }), ]) @@ -436,7 +530,7 @@ medusaIntegrationTestRunner({ expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product_filtering_4", + id: deletedProduct.id, }), ]) ) @@ -455,13 +549,7 @@ medusaIntegrationTestRunner({ it("returns a list of products with free text query including variant prices", async () => { const response = await api - .get( - `/admin/products?q=${breaking( - () => "test+product1", - () => v2Product.description - )}`, - adminHeaders - ) + .get(`/admin/products?q=${baseProduct.description}`, adminHeaders) .catch((err) => { console.log(err) }) @@ -475,13 +563,19 @@ medusaIntegrationTestRunner({ expect.arrayContaining([ expect.objectContaining({ id: breaking( - () => "test-price_4", + () => expect.stringMatching(/^ma_*/), () => expect.stringMatching(/^price_*/) ), }), expect.objectContaining({ id: breaking( - () => "test-price_3", + () => expect.stringMatching(/^ma_*/), + () => expect.stringMatching(/^price_*/) + ), + }), + expect.objectContaining({ + id: breaking( + () => expect.stringMatching(/^ma_*/), () => expect.stringMatching(/^price_*/) ), }), @@ -497,12 +591,7 @@ medusaIntegrationTestRunner({ }) expect(response.status).toEqual(200) - expect(response.data.products.length).toEqual( - breaking( - () => 4, - () => 5 - ) - ) + expect(response.data.products.length).toEqual(2) }) it("returns a list of deleted products", async () => { @@ -523,7 +612,7 @@ medusaIntegrationTestRunner({ expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product_filtering_4", + id: deletedProduct.id, }), ]) ) @@ -531,92 +620,12 @@ medusaIntegrationTestRunner({ it("returns a list of products in collection", async () => { const notExpected = [ - expect.objectContaining({ collection_id: "test-collection" }), - expect.objectContaining({ collection_id: "test-collection2" }), + expect.objectContaining({ collection_id: publishedCollection.id }), ] const response = await api .get( - "/admin/products?collection_id[]=test-collection1", - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(2) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "test-product_filtering_1", - collection_id: "test-collection1", - }), - expect.objectContaining({ - id: "test-product_filtering_3", - collection_id: "test-collection1", - }), - ]) - ) - - for (const notExpect of notExpected) { - expect(response.data.products).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - }) - - it("returns a list of products with tags", async () => { - const notExpected = [ - expect.objectContaining({ id: "tag1" }), - expect.objectContaining({ id: "tag2" }), - expect.objectContaining({ id: "tag4" }), - ] - - const response = await api - .get("/admin/products?tags[]=tag3", adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(2) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "test-product_filtering_1", - tags: [expect.objectContaining({ id: "tag3" })], - }), - expect.objectContaining({ - id: "test-product_filtering_2", - tags: [expect.objectContaining({ id: "tag3" })], - }), - ]) - ) - for (const product of response.data.products) { - for (const notExpect of notExpected) { - expect(product.tags).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - } - }) - - it("returns a list of products with tags in a collection", async () => { - const notExpectedTags = [ - expect.objectContaining({ id: "tag1" }), - expect.objectContaining({ id: "tag2" }), - expect.objectContaining({ id: "tag3" }), - ] - - const notExpectedCollections = [ - expect.objectContaining({ collection_id: "test-collection" }), - expect.objectContaining({ collection_id: "test-collection2" }), - ] - - const response = await api - .get( - "/admin/products?collection_id[]=test-collection1&tags[]=tag4", + `/admin/products?collection_id[]=${baseCollection.id}`, adminHeaders ) .catch((err) => { @@ -628,26 +637,65 @@ medusaIntegrationTestRunner({ expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product_filtering_3", - collection_id: "test-collection1", - tags: [expect.objectContaining({ id: "tag4" })], + id: baseProduct.id, + collection_id: baseCollection.id, }), ]) ) - for (const notExpect of notExpectedCollections) { + for (const notExpect of notExpected) { expect(response.data.products).toEqual( expect.not.arrayContaining([notExpect]) ) } + }) - for (const product of response.data.products) { - for (const notExpect of notExpectedTags) { - expect(product.tags).toEqual( - expect.not.arrayContaining([notExpect]) - ) - } - } + // TODO: Enforce tag uniqueness in product module + it.skip("returns a list of products with tags", async () => { + const response = await api.get( + `/admin/products?tags[]=${baseProduct.tags[0].id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(2) + expect(response.data.products).toEqual( + expect.arrayContaining( + [ + expect.objectContaining({ + id: baseProduct.id, + tags: [ + expect.objectContaining({ id: baseProduct.tags[0].id }), + ], + }), + ], + expect.objectContaining({ + id: publishedProduct.id, + // It should be the same tag instance in both products + tags: [expect.objectContaining({ id: baseProduct.tags[0].id })], + }) + ) + ) + }) + + // TODO: Enforce tag uniqueness in product module + it.skip("returns a list of products with tags in a collection", async () => { + const response = await api.get( + `/admin/products?collection_id[]=${baseCollection.id}&tags[]=${baseProduct.tags[0].id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.id, + collection_id: baseCollection.id, + tags: [expect.objectContaining({ id: baseProduct.tags[0].id })], + }), + ]) + ) }) it("returns a list of products with only giftcard in list", async () => { @@ -791,6 +839,7 @@ medusaIntegrationTestRunner({ console.log(err) }) + expect(response.data.products).toHaveLength(3) expect(response.data.products).toEqual( expect.not.arrayContaining([ expect.objectContaining({ is_giftcard: true }), @@ -798,45 +847,43 @@ medusaIntegrationTestRunner({ ) }) - // TODO: Enable once there is a data migration to migrate variant options - it.skip("returns a list of products with child entities", async () => { + it("returns a list of products with child entities", async () => { const response = await api .get("/admin/products?order=created_at", adminHeaders) .catch((err) => { console.log(err) }) - // TODO: Enable other assertions once supported - expect(response.data.products).toHaveLength(5) + expect(response.data.products).toHaveLength(3) expect(response.data.products).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-product", + id: baseProduct.id, options: expect.arrayContaining([ expect.objectContaining({ - id: expect.stringMatching(/^test-*/), - product_id: expect.stringMatching(/^test-*/), + id: expect.stringMatching(/^opt_*/), + product_id: expect.stringMatching(/^prod_*/), created_at: expect.any(String), updated_at: expect.any(String), }), ]), images: expect.arrayContaining([ expect.objectContaining({ - id: expect.stringMatching(/^test-*/), + id: expect.stringMatching(/^img_*/), created_at: expect.any(String), updated_at: expect.any(String), }), ]), variants: expect.arrayContaining([ expect.objectContaining({ - id: "test-variant", + id: baseProduct.variants[0].id, created_at: expect.any(String), updated_at: expect.any(String), - product_id: expect.stringMatching(/^test-*/), + product_id: expect.stringMatching(/^prod_*/), prices: expect.arrayContaining([ expect.objectContaining({ - id: "test-price", - variant_id: expect.stringMatching(/^test-variant*/), + id: baseProduct.variants[0].prices[0].id, + variant_id: expect.stringMatching(/^variant_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -845,9 +892,9 @@ medusaIntegrationTestRunner({ () => expect.arrayContaining([ expect.objectContaining({ - id: expect.stringMatching(/^test-variant-option*/), - variant_id: expect.stringMatching(/^test-variant*/), - option_id: expect.stringMatching(/^test-opt*/), + id: expect.stringMatching(/^optval_*/), + variant_id: expect.stringMatching(/^variant_*/), + option_id: expect.stringMatching(/^opt_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -857,112 +904,7 @@ medusaIntegrationTestRunner({ expect.objectContaining({ id: expect.stringMatching(/^varopt_*/), option_value: expect.objectContaining({ - value: "100", - }), - }), - ]) - ), - }), - expect.objectContaining({ - id: "test-variant_2", - created_at: expect.any(String), - updated_at: expect.any(String), - product_id: expect.stringMatching(/^test-*/), - prices: expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-price*/), - variant_id: "test-variant_2", - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - options: breaking( - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-variant-option*/), - variant_id: expect.stringMatching(/^test-variant*/), - option_id: expect.stringMatching(/^test-opt*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^varopt_*/), - option_value: expect.objectContaining({ - value: "100", - }), - }), - ]) - ), - }), - expect.objectContaining({ - id: "test-variant_1", - created_at: expect.any(String), - updated_at: expect.any(String), - product_id: expect.stringMatching(/^test-*/), - prices: expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-price*/), - variant_id: expect.stringMatching(/^test-variant*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - options: breaking( - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-variant-option*/), - variant_id: expect.stringMatching(/^test-variant*/), - option_id: expect.stringMatching(/^test-opt*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^varopt_*/), - option_value: expect.objectContaining({ - value: "100", - }), - }), - ]) - ), - }), - expect.objectContaining({ - id: "test-variant-sale", - created_at: expect.any(String), - updated_at: expect.any(String), - product_id: expect.stringMatching(/^test-*/), - prices: expect.arrayContaining([ - expect.objectContaining({ - id: "test-price-sale", - variant_id: expect.stringMatching(/^test-variant*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - options: breaking( - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-variant-option*/), - variant_id: expect.stringMatching(/^test-variant*/), - option_id: expect.stringMatching(/^test-opt*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^varopt_*/), - option_value: expect.objectContaining({ - value: "100", + value: "large", }), }), ]) @@ -971,50 +913,41 @@ medusaIntegrationTestRunner({ ]), tags: expect.arrayContaining([ expect.objectContaining({ - id: expect.stringMatching(/^tag*/), + id: expect.stringMatching(/^ptag_*/), created_at: expect.any(String), updated_at: expect.any(String), }), ]), type: expect.objectContaining({ - id: expect.stringMatching(/^test-*/), + id: expect.stringMatching(/^ptyp_*/), created_at: expect.any(String), updated_at: expect.any(String), }), collection: expect.objectContaining({ - id: expect.stringMatching(/^test-*/), + id: expect.stringMatching(/^pcol_*/), created_at: expect.any(String), updated_at: expect.any(String), }), - // profile_id: expect.stringMatching(/^sp_*/), + // // profile_id: expect.stringMatching(/^sp_*/), created_at: expect.any(String), updated_at: expect.any(String), }), expect.objectContaining({ - id: "test-product1", + id: proposedProduct.id, created_at: expect.any(String), - options: [], variants: expect.arrayContaining([ expect.objectContaining({ - id: "test-variant_4", + id: proposedProduct.variants[0].id, created_at: expect.any(String), updated_at: expect.any(String), - product_id: expect.stringMatching(/^test-*/), - prices: expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-price*/), - variant_id: expect.stringMatching(/^test-variant*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), + product_id: expect.stringMatching(/^prod_*/), options: breaking( () => expect.arrayContaining([ expect.objectContaining({ - id: expect.stringMatching(/^test-variant-option*/), - variant_id: expect.stringMatching(/^test-variant*/), - option_id: expect.stringMatching(/^test-opt*/), + id: expect.stringMatching(/^optval_*/), + variant_id: expect.stringMatching(/^variant_*/), + option_id: expect.stringMatching(/^opt_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -1024,42 +957,7 @@ medusaIntegrationTestRunner({ expect.objectContaining({ id: expect.stringMatching(/^varopt_*/), option_value: expect.objectContaining({ - value: "100", - }), - }), - ]) - ), - }), - expect.objectContaining({ - id: "test-variant_3", - created_at: expect.any(String), - updated_at: expect.any(String), - product_id: expect.stringMatching(/^test-*/), - prices: expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-price*/), - variant_id: expect.stringMatching(/^test-variant*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - options: breaking( - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^test-variant-option*/), - variant_id: expect.stringMatching(/^test-variant*/), - option_id: expect.stringMatching(/^test-opt*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - () => - expect.arrayContaining([ - expect.objectContaining({ - id: expect.stringMatching(/^varopt_*/), - option_value: expect.objectContaining({ - value: "100", + value: "green", }), }), ]) @@ -1068,48 +966,22 @@ medusaIntegrationTestRunner({ ]), tags: expect.arrayContaining([ expect.objectContaining({ - id: expect.stringMatching(/^tag*/), + id: expect.stringMatching(/^ptag*/), created_at: expect.any(String), updated_at: expect.any(String), }), ]), type: expect.objectContaining({ - id: expect.stringMatching(/^test-*/), - created_at: expect.any(String), - updated_at: expect.any(String), - }), - collection: expect.objectContaining({ - id: expect.stringMatching(/^test-*/), + id: expect.stringMatching(/^ptyp-*/), created_at: expect.any(String), updated_at: expect.any(String), }), + collection: null, // profile_id: expect.stringMatching(/^sp_*/), updated_at: expect.any(String), }), expect.objectContaining({ - id: "test-product_filtering_1", - // profile_id: expect.stringMatching(/^sp_*/), - created_at: expect.any(String), - type: expect.any(Object), - collection: expect.any(Object), - options: expect.any(Array), - tags: expect.any(Array), - variants: expect.any(Array), - updated_at: expect.any(String), - }), - expect.objectContaining({ - id: "test-product_filtering_2", - // profile_id: expect.stringMatching(/^sp_*/), - created_at: expect.any(String), - type: expect.any(Object), - collection: expect.any(Object), - options: expect.any(Array), - tags: expect.any(Array), - variants: expect.any(Array), - updated_at: expect.any(String), - }), - expect.objectContaining({ - id: "test-product_filtering_3", + id: publishedProduct.id, // profile_id: expect.stringMatching(/^sp_*/), created_at: expect.any(String), type: expect.any(Object), @@ -1129,7 +1001,7 @@ medusaIntegrationTestRunner({ return await simplePriceListFactory(dbConnection, { prices: [ { - variant_id: "test-variant", + variant_id: baseProduct.variants[0].id, amount: 100, currency_code: "usd", }, @@ -1137,20 +1009,17 @@ medusaIntegrationTestRunner({ }) }, async () => { - const variantId = v2Product.variants[0].id - + const variantId = baseProduct.variants[0].id await pricingService.createRuleTypes([ { name: "Region ID", rule_attribute: "region_id", }, ]) - const priceSet = await createVariantPriceSet({ container, variantId, }) - const [priceList] = await pricingService.createPriceLists([ { title: "Test price list", @@ -1183,38 +1052,25 @@ medusaIntegrationTestRunner({ expect(res.data.products.length).toEqual(1) expect(res.data.products).toEqual([ expect.objectContaining({ - id: breaking( - () => "test-product", - () => v2Product.id - ), - status: "draft", + id: baseProduct.id, + status: baseProduct.status, }), ]) }) it("should return products filtered by sales_channel_id", async () => { - const { salesChannel, product } = await breaking( + const { salesChannel } = await breaking( async () => { - const product = await simpleProductFactory(dbConnection, { - id: "product_1", - title: "test title", - }) - - await simpleProductFactory(dbConnection, { - id: "product_2", - title: "test title 2", - }) - const salesChannel = await simpleSalesChannelFactory( dbConnection, { name: "test name", description: "test description", - products: [product], + products: [baseProduct], } ) - return { salesChannel, product } + return { salesChannel } }, async () => { const salesChannel = await scService.create({ @@ -1224,14 +1080,14 @@ medusaIntegrationTestRunner({ await remoteLink.create({ [Modules.PRODUCT]: { - product_id: v2Product.id, + product_id: baseProduct.id, }, [Modules.SALES_CHANNEL]: { sales_channel_id: salesChannel.id, }, }) - return { salesChannel, product: v2Product } + return { salesChannel } } ) @@ -1244,7 +1100,7 @@ medusaIntegrationTestRunner({ expect(res.data.products.length).toEqual(1) expect(res.data.products).toEqual([ expect.objectContaining({ - id: product.id, + id: baseProduct.id, status: "draft", }), ]) @@ -1252,29 +1108,9 @@ medusaIntegrationTestRunner({ }) describe("GET /admin/products/:id", () => { - const productId = "testing-get-product" - - beforeEach(async () => { - await simpleProductFactory(dbConnection, { - id: productId, - variants: [ - { - title: "Test variant", - prices: [ - { - currency: "usd", - amount: 100, - }, - ], - }, - ], - }) - }) - it("should get a product with default relations", async () => { - const testProductId = v2Product?.id ?? productId const res = await api - .get(`/admin/products/${testProductId}`, adminHeaders) + .get(`/admin/products/${baseProduct.id}`, adminHeaders) .catch((err) => { console.log(err) }) @@ -1282,7 +1118,7 @@ medusaIntegrationTestRunner({ const keysInResponse = Object.keys(res.data.product) expect(res.status).toEqual(200) - expect(res.data.product.id).toEqual(testProductId) + expect(res.data.product.id).toEqual(baseProduct.id) expect(keysInResponse).toEqual( expect.arrayContaining([ "id", @@ -1330,10 +1166,9 @@ medusaIntegrationTestRunner({ }) it("should get a product with prices", async () => { - const testProductId = v2Product?.id ?? productId const res = await api .get( - `/admin/products/${testProductId}?${breaking( + `/admin/products/${baseProduct.id}?${breaking( () => "expand=variants,variants.prices", () => "fields=*variants,*variants.prices" )}`, @@ -1345,7 +1180,7 @@ medusaIntegrationTestRunner({ const { id, variants } = res.data.product - expect(id).toEqual(testProductId) + expect(id).toEqual(baseProduct.id) expect(variants[0].prices).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1357,10 +1192,9 @@ medusaIntegrationTestRunner({ }) it("should get a product only with variants expanded", async () => { - const testProductId = v2Product?.id ?? productId const res = await api .get( - `/admin/products/${testProductId}?${breaking( + `/admin/products/${baseProduct.id}?${breaking( () => "expand=variants", () => "fields=title,*variants" )}`, @@ -1372,7 +1206,7 @@ medusaIntegrationTestRunner({ const { id, variants } = res.data.product - expect(id).toEqual(testProductId) + expect(id).toEqual(baseProduct.id) expect(variants[0]).toEqual( expect.objectContaining({ title: "Test variant", @@ -1385,8 +1219,6 @@ medusaIntegrationTestRunner({ describe("POST /admin/products", () => { beforeEach(async () => { - await productSeeder(dbConnection) - await simpleSalesChannelFactory(dbConnection, { name: "Default channel", id: "default-channel", @@ -1398,15 +1230,15 @@ medusaIntegrationTestRunner({ const response = await api .post( "/admin/products", - { - ...getProductFixture(), - ...breaking( - () => ({ type: { value: "test-type" } }), - () => ({ type_id: "test-type" }) - ), + getProductFixture({ title: "Test create", - collection_id: "test-collection", - }, + collection_id: baseCollection.id, + ...breaking( + () => ({ type: { value: baseType.value } }), + () => ({ type_id: baseType.id }) + ), + }), + adminHeaders ) .catch((err) => { @@ -1418,7 +1250,6 @@ medusaIntegrationTestRunner({ () => /^price_*/ ) - // TODO: It seems we end up with this recursive nested population (product -> variant -> product) that we need to get rid of expect(response.status).toEqual(200) expect(response.data.product).toEqual( expect.objectContaining({ @@ -1461,13 +1292,13 @@ medusaIntegrationTestRunner({ }), ]), type: expect.objectContaining({ - value: "test-type", + value: baseType.value, created_at: expect.any(String), updated_at: expect.any(String), }), collection: expect.objectContaining({ - id: "test-collection", - title: "Test collection", + id: baseCollection.id, + title: baseCollection.title, created_at: expect.any(String), updated_at: expect.any(String), }), @@ -1593,7 +1424,7 @@ medusaIntegrationTestRunner({ () => ["test-image.png", "test-image-2.png"], () => [{ url: "test-image.png" }, { url: "test-image-2.png" }] ), - collection_id: "test-collection", + collection_id: baseCollection.id, tags: [{ value: "123" }, { value: "456" }], variants: [ { @@ -1626,7 +1457,7 @@ medusaIntegrationTestRunner({ () => ["test-image.png", "test-image-2.png"], () => [{ url: "test-image.png" }, { url: "test-image-2.png" }] ), - collection_id: "test-collection", + collection_id: baseCollection.id, tags: [{ value: "123" }, { value: "456" }], variants: [ { @@ -1702,21 +1533,29 @@ medusaIntegrationTestRunner({ ) }) - // TODO: Remove price setting on nested objects per the code convention. - // TODO: The variants list requires a product_id currently, that should not be needed. - it.skip("updates a product (update prices, tags, update status, delete collection, delete type, replaces images)", async () => { + it("updates a product (update prices, tags, update status, delete collection, delete type, replaces images)", async () => { const payload = { collection_id: null, + title: "Test an update", variants: [ { - id: "test-variant", + id: baseProduct.variants[0].id, title: "New variant", - // prices: [ - // { - // currency_code: "usd", - // amount: 75, - // }, - // ], + barcode: "test-barcode", + ean: "test-ean", + upc: "test-upc", + // Price updates should happen through the variant's dedicated endpoint + ...breaking( + () => ({ + prices: [ + { + currency_code: "usd", + amount: 75, + }, + ], + }), + () => ({}) + ), }, ], tags: [{ value: "123" }], @@ -1728,7 +1567,7 @@ medusaIntegrationTestRunner({ } const response = await api - .post("/admin/products/test-product", payload, adminHeaders) + .post(`/admin/products/${baseProduct.id}`, payload, adminHeaders) .catch((err) => { console.log(err) }) @@ -1737,11 +1576,13 @@ medusaIntegrationTestRunner({ expect(response.data.product).toEqual( expect.objectContaining({ - id: "test-product", + id: baseProduct.id, + title: "Test an update", created_at: expect.any(String), description: "test-product-description", discountable: true, - handle: "test-product", + // TODO: Do an update on the name, see if the handle is constant + handle: "base-product", images: expect.arrayContaining([ expect.objectContaining({ created_at: expect.any(String), @@ -1754,9 +1595,9 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ created_at: expect.any(String), - id: "test-option", - product_id: "test-product", - title: "test-option", + id: expect.stringMatching(/^opt_*/), + product_id: baseProduct.id, + title: "size", ...breaking( () => ({}), () => ({ @@ -1768,7 +1609,7 @@ medusaIntegrationTestRunner({ updated_at: expect.any(String), }), ]), - // profile_id: expect.stringMatching(/^sp_*/), + // // profile_id: expect.stringMatching(/^sp_*/), status: "published", tags: expect.arrayContaining([ expect.objectContaining({ @@ -1779,23 +1620,24 @@ medusaIntegrationTestRunner({ value: "123", }), ]), - thumbnail: "test-image-2.png", - title: "Test product", + // TODO: Decide how to handle the thumbnail on update + // thumbnail: "test-image-2.png", type: expect.objectContaining({ - id: expect.stringMatching(/^ptyp_*/), + id: baseType.id, created_at: expect.any(String), updated_at: expect.any(String), - value: "test-type-2", + value: baseType.value, }), - type_id: expect.stringMatching(/^ptyp_*/), + type_id: baseType.id, updated_at: expect.any(String), variants: expect.arrayContaining([ expect.objectContaining({ allow_backorder: false, barcode: "test-barcode", - created_at: expect.any(String), ean: "test-ean", - id: "test-variant", + upc: "test-upc", + created_at: expect.any(String), + id: baseProduct.variants[0].id, inventory_quantity: 10, manage_inventory: true, options: breaking( @@ -1804,12 +1646,12 @@ medusaIntegrationTestRunner({ expect.objectContaining({ created_at: expect.any(String), deleted_at: null, - id: "test-variant-option", + id: expect.stringMatching(/^optval_*/), metadata: null, - option_id: "test-option", + option_id: expect.stringMatching(/^opt_*/), updated_at: expect.any(String), - value: "Default variant", - variant_id: "test-variant", + value: "large", + variant_id: baseProduct.variants[0].id, }), ]), () => @@ -1817,29 +1659,38 @@ medusaIntegrationTestRunner({ expect.objectContaining({ id: expect.stringMatching(/^varopt_*/), option_value: expect.objectContaining({ - value: "Default variant", + value: "large", option: expect.objectContaining({ - title: "Test option", + title: "size", }), }), }), ]) ), origin_country: null, - // prices: expect.arrayContaining([ - // expect.objectContaining({ - // amount: 75, - // created_at: expect.any(String), - // currency_code: "usd", - // id: "test-price", - // updated_at: expect.any(String), - // variant_id: "test-variant", - // }), - // ]), - product_id: "test-product", - sku: "test-sku", + prices: breaking( + () => + expect.arrayContaining([ + expect.objectContaining({ + amount: 75, + created_at: expect.any(String), + currency_code: "usd", + updated_at: expect.any(String), + variant_id: baseProduct.variants[0].id, + }), + ]), + // Price updates can't happen through the product endpoint in v2 + () => + expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + created_at: expect.any(String), + currency_code: "usd", + }), + ]) + ), + product_id: baseProduct.id, title: "New variant", - upc: "test-upc", updated_at: expect.any(String), }), ]), @@ -1847,32 +1698,36 @@ medusaIntegrationTestRunner({ ) }) - // TODO: Decide if we we should actually remove the images, as they are a many-to-many relationship currently - it.skip("updates product (removes images when empty array included)", async () => { + it("updates product (removes images when empty array included)", async () => { const payload = { images: [], } const response = await api - .post("/admin/products/test-product", payload, adminHeaders) + .post(`/admin/products/${baseProduct.id}`, payload, adminHeaders) .catch((err) => { console.log(err) }) expect(response.status).toEqual(200) - expect(response.data.product.images.length).toEqual(0) }) - // TODO: Currently we replace the metadata completely, in v1 it would do some diffing. Which approach do we want for v2? - it.skip("updates a product by deleting a field from metadata", async () => { - const product = await simpleProductFactory(dbConnection, { - metadata: { - "test-key": "test-value", - "test-key-2": "test-value-2", - "test-key-3": "test-value-3", - }, - }) + it("updates a product by deleting a field from metadata", async () => { + const created = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Test metadata", + metadata: { + "test-key": "test-value", + "test-key-2": "test-value-2", + "test-key-3": "test-value-3", + }, + }), + adminHeaders + ) + ).data.product const payload = { metadata: { @@ -1882,16 +1737,24 @@ medusaIntegrationTestRunner({ } const response = await api.post( - "/admin/products/" + product.id, + "/admin/products/" + created.id, payload, adminHeaders ) expect(response.status).toEqual(200) - expect(response.data.product.metadata).toEqual({ - "test-key-2": null, - "test-key-3": "test-value-3", - }) + expect(response.data.product.metadata).toEqual( + breaking( + () => ({ + "test-key-2": null, + "test-key-3": "test-value-3", + }), + () => ({ + "test-key": "", + "test-key-2": null, + }) + ) + ) }) it("fails to update product with invalid status", async () => { @@ -1901,7 +1764,7 @@ medusaIntegrationTestRunner({ try { await api.post( - "/admin/products/test-product", + `/admin/products/${baseProduct}`, payload, adminHeaders ) @@ -1911,25 +1774,34 @@ medusaIntegrationTestRunner({ } }) - it.skip("updates a product (variant ordering)", async () => { + it("updates a product (variant ordering)", async () => { + const plainProduct = ( + await api.post( + "/admin/products", + { title: "Test variant order" }, + adminHeaders + ) + ).data.product + const payload = { - collection_id: null, - type: null, variants: [ { - id: "test-variant", + title: "first", + inventory_quantity: 10, }, { - id: "test-variant_1", + title: "second", + inventory_quantity: 10, }, { - id: "test-variant_2", + title: "third", + inventory_quantity: 10, }, ], } const response = await api - .post("/admin/products/test-product", payload, adminHeaders) + .post(`/admin/products/${plainProduct.id}`, payload, adminHeaders) .catch((err) => { console.log(err) }) @@ -1938,36 +1810,37 @@ medusaIntegrationTestRunner({ expect(response.data.product).toEqual( expect.objectContaining({ - title: "Test product", - // TODO: Variants are not handled correctly, investigate - // variants: [ - // expect.objectContaining({ - // id: "test-variant", - // title: "Test variant", - // }), - // expect.objectContaining({ - // id: "test-variant_1", - // title: "Test variant rank (1)", - // }), - // expect.objectContaining({ - // id: "test-variant_2", - // title: "Test variant rank (2)", - // }), - // ], - type: null, - collection: null, + title: plainProduct.title, + variants: [ + expect.objectContaining({ + title: "first", + }), + expect.objectContaining({ + title: "second", + }), + expect.objectContaining({ + title: "third", + }), + ], }) ) }) - // TODO: Add option handling once migrated to new breaking change - it.skip("add option", async () => { + it("add option", async () => { const payload = { title: "should_add", + ...breaking( + () => ({}), + () => ({ values: ["100"] }) + ), } const response = await api - .post("/admin/products/test-product/options", payload, adminHeaders) + .post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) .catch((err) => { console.log(err) }) @@ -1979,7 +1852,15 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ title: "should_add", - product_id: "test-product", + product_id: baseProduct.id, + ...breaking( + () => ({}), + () => ({ + values: expect.arrayContaining([ + expect.objectContaining({ value: "100" }), + ]), + }) + ), }), ]), }) @@ -1988,50 +1869,10 @@ medusaIntegrationTestRunner({ }) describe("DELETE /admin/products/:id/options/:option_id", () => { - let product1 - let product2 - - beforeEach(async () => { - const payload = { - title: "Test product options", - options: [ - { - title: "Test option", - ...breaking( - () => {}, - () => ({ values: ["100"] }) - ), - }, - ], - } - product1 = (await api.post("/admin/products", payload, adminHeaders)) - .data.product - - const payload2 = { - ...payload, - title: "Test product options with variant", - variants: [ - { - title: "Variant", - prices: [], - options: breaking( - () => [{ value: "100" }], - () => ({ - "Test option": "100", - }) - ), - }, - ], - } - - product2 = (await api.post("/admin/products", payload2, adminHeaders)) - .data.product - }) - it("deletes a product option", async () => { const response = await api .delete( - `/admin/products/${product1.id}/options/${product1.options[0].id}`, + `/admin/products/${baseProduct.id}/options/${baseProduct.options[0].id}`, adminHeaders ) .catch((err) => { @@ -2043,17 +1884,23 @@ medusaIntegrationTestRunner({ () => { expect(response.data.product).toEqual( expect.objectContaining({ - options: [], - id: product1.id, - variants: [], + options: [ + expect.objectContaining({ + title: baseProduct.options[1].title, + }), + ], + id: baseProduct.id, }) ) }, () => { expect(response.data).toEqual( expect.objectContaining({ - id: product1.options[0].id, + id: baseProduct.options[0].id, object: "product_option", + parent: expect.objectContaining({ + id: baseProduct.id, + }), }) ) } @@ -2062,14 +1909,14 @@ medusaIntegrationTestRunner({ it("deletes a values associated with deleted option", async () => { await api.delete( - `/admin/products/${product2.id}/options/${product2.options[0].id}`, + `/admin/products/${baseProduct.id}/options/${baseProduct.options[0].id}`, adminHeaders ) const values = await breaking( async () => await dbConnection.manager.find(ProductOptionValue, { - where: { option_id: product2.options[0].id }, + where: { option_id: baseProduct.options[0].id }, withDeleted: true, }), async () => { @@ -2078,7 +1925,7 @@ medusaIntegrationTestRunner({ ) return await productModule.listOptions( - { id: product2.options[0].id }, + { id: baseProduct.options[0].id }, { withDeleted: true } ) } @@ -2091,50 +1938,29 @@ medusaIntegrationTestRunner({ }) describe("GET /admin/products/:id/variants", () => { - beforeEach(async () => { - await productSeeder(dbConnection) - await simpleSalesChannelFactory(dbConnection, { - name: "Default channel", - id: "default-channel", - is_default: true, - }) - }) - it("should return the variants related to the requested product", async () => { const res = await api - .get("/admin/products/test-product/variants", adminHeaders) + .get(`/admin/products/${baseProduct.id}/variants`, adminHeaders) .catch((err) => { console.log(err) }) expect(res.status).toEqual(200) - expect(res.data.variants.length).toBe(4) + expect(res.data.variants.length).toBe(1) expect(res.data.variants).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "test-variant", - product_id: "test-product", - }), - expect.objectContaining({ - id: "test-variant_1", - product_id: "test-product", - }), - expect.objectContaining({ - id: "test-variant_2", - product_id: "test-product", - }), - expect.objectContaining({ - id: "test-variant-sale", - product_id: "test-product", + id: baseProduct.variants[0].id, + product_id: baseProduct.id, }), ]) ) }) }) + // TODO: Enable variant price updates describe.skip("updates a variant's default prices (ignores prices associated with a Price List)", () => { beforeEach(async () => { - await productSeeder(dbConnection) await priceListSeeder(dbConnection) await simpleSalesChannelFactory(dbConnection, { name: "Default channel", @@ -2497,14 +2323,14 @@ medusaIntegrationTestRunner({ }) it("successfully updates a variants prices by deleting both a currency and region price", async () => { - await Promise.all( - ["reg_1", "reg_2", "reg_3"].map(async (regionId) => { - return await simpleRegionFactory(dbConnection, { - id: regionId, - currency_code: regionId === "reg_1" ? "eur" : "usd", - }) - }) - ) + // await Promise.all( + // ["reg_1", "reg_2", "reg_3"].map(async (regionId) => { + // return await simpleRegionFactory(dbConnection, { + // id: regionId, + // currency_code: regionId === "reg_1" ? "eur" : "usd", + // }) + // }) + // ) const createPrices = { prices: [ @@ -2592,27 +2418,14 @@ medusaIntegrationTestRunner({ }) describe("variant creation", () => { - beforeEach(async () => { - try { - await productSeeder(dbConnection) - await simpleSalesChannelFactory(dbConnection, { - name: "Default channel", - id: "default-channel", - is_default: true, - }) - } catch (err) { - console.log(err) - throw err - } - }) - it("create a product variant with prices (regional and currency)", async () => { const payload = { - title: "New variant", + title: "Created variant", sku: "new-sku", ean: "new-ean", upc: "new-upc", barcode: "new-barcode", + inventory_quantity: 10, prices: [ { currency_code: "usd", @@ -2620,7 +2433,7 @@ medusaIntegrationTestRunner({ }, { ...breaking( - () => ({ region_id: "test-region" }), + () => ({ region_id: baseRegion.id }), () => ({ currency_code: "eur" }) ), amount: 200, @@ -2629,16 +2442,24 @@ medusaIntegrationTestRunner({ ...breaking( () => ({ options: [ - { option_id: "test-option", value: "inserted value" }, + { + option_id: baseProduct.options[0].id, + value: "newval", + }, + { + option_id: baseProduct.options[1].id, + value: "newval", + }, ], }), + // TODO: Require that options are passed if they belong on the product, and the combos are unique per variant () => ({}) ), } const res = await api .post( - "/admin/products/test-product/variants", + `/admin/products/${baseProduct.id}/variants`, payload, adminHeaders ) @@ -2675,7 +2496,7 @@ medusaIntegrationTestRunner({ variant_id: insertedVariant.id, ...breaking( () => ({ - region_id: "test-region", + region_id: baseRegion.id, min_quantity: null, max_quantity: null, price_list_id: null, @@ -2689,18 +2510,9 @@ medusaIntegrationTestRunner({ }) describe("testing for soft-deletion + uniqueness on handles, collection and variant properties", () => { - beforeEach(async () => { - await productSeeder(dbConnection) - await simpleSalesChannelFactory(dbConnection, { - name: "Default channel", - id: "default-channel", - is_default: true, - }) - }) - it("successfully deletes a product", async () => { const response = await api - .delete("/admin/products/test-product", adminHeaders) + .delete(`/admin/products/${baseProduct.id}`, adminHeaders) .catch((err) => { console.log(err) }) @@ -2709,24 +2521,36 @@ medusaIntegrationTestRunner({ expect(response.data).toEqual( expect.objectContaining({ - id: "test-product", + id: baseProduct.id, deleted: true, }) ) }) it("successfully deletes a product and variants", async () => { - const variantPre = await dbConnection.manager.findOne( - ProductVariant, - { - where: { id: "test-variant" }, + const variantPre = await breaking( + async () => { + return ( + await api.get( + `/admin/products/${baseProduct.id}/variants`, + adminHeaders + ) + ).data.variants[0] + }, + async () => { + return ( + await api.get( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, + adminHeaders + ) + ).data.variant } ) expect(variantPre).toBeTruthy() const response = await api - .delete("/admin/products/test-product", adminHeaders) + .delete(`/admin/products/${baseProduct.id}`, adminHeaders) .catch((err) => { console.log(err) }) @@ -2735,16 +2559,30 @@ medusaIntegrationTestRunner({ expect(response.data).toEqual( expect.objectContaining({ - id: "test-product", + id: baseProduct.id, deleted: true, }) ) + const variantPost = await breaking( + async () => { + return ( + await api.get( + `/admin/products/${baseProduct.id}/variants`, + adminHeaders + ) + ).data.variants[0] + }, + async () => { + return ( + await api.get( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, + adminHeaders + ) + ).data.variant + } + ) - const variant = await dbConnection.manager.findOne(ProductVariant, { - where: { id: "test-variant" }, - }) - - expect(variant).not.toBeTruthy() + expect(variantPost).not.toBeTruthy() }) // TODO: This one is a bit more complex, leaving for later @@ -3008,13 +2846,13 @@ medusaIntegrationTestRunner({ it("successfully deletes product collection", async () => { // First we soft-delete the product collection const response = await api - .delete("/admin/collections/test-collection", adminHeaders) + .delete(`/admin/collections/${baseCollection.id}`, adminHeaders) .catch((err) => { console.log(err) }) expect(response.status).toEqual(200) - expect(response.data.id).toEqual("test-collection") + expect(response.data.id).toEqual(baseCollection.id) }) // TODO: This needs to be fixed, it returns 422 now. @@ -3048,14 +2886,14 @@ medusaIntegrationTestRunner({ // Lets try to create a collection with same handle as deleted one const payload = { title: "Another test collection", - handle: "test-collection", + handle: baseCollection.handle, } try { await api.post("/admin/collections", payload, adminHeaders) } catch (error) { expect(error.response.data.message).toMatch( - "Product_collection with handle test-collection already exists." + `Product_collection with handle ${baseCollection.handle} already exists.` ) } }) @@ -3123,21 +2961,10 @@ medusaIntegrationTestRunner({ }) describe("POST /admin/products/:id/variants/:id", () => { - beforeEach(async () => { - await simpleProductFactory(dbConnection, { - id: "test-product-to-update", - variants: [ - { - id: "test-variant-to-update", - }, - ], - }) - }) - it("successfully updates variant without prices", async () => { const res = await api .post( - "/admin/products/test-product-to-update/variants/test-variant-to-update", + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, { title: "Updated variant", }, @@ -3148,13 +2975,17 @@ medusaIntegrationTestRunner({ }) expect(res.status).toEqual(200) + expect( + res.data.product.variants.find( + (v) => v.id === baseProduct.variants[0].id + )?.title + ).toEqual("Updated variant") }) }) // TODO: Discuss how this should be handled describe.skip("GET /admin/products/tag-usage", () => { beforeEach(async () => { - await productSeeder(dbConnection) await simpleSalesChannelFactory(dbConnection, { name: "Default channel", id: "default-channel", diff --git a/packages/core-flows/src/pricing/steps/update-price-sets.ts b/packages/core-flows/src/pricing/steps/update-price-sets.ts index d2e40c95af..43b30f9357 100644 --- a/packages/core-flows/src/pricing/steps/update-price-sets.ts +++ b/packages/core-flows/src/pricing/steps/update-price-sets.ts @@ -7,13 +7,17 @@ import { import { StepResponse, createStep } from "@medusajs/workflows-sdk" type UpdatePriceSetsStepInput = { - selector: PricingTypes.FilterablePriceSetProps - update: PricingTypes.UpdatePriceSetDTO + selector?: PricingTypes.FilterablePriceSetProps + update?: PricingTypes.UpdatePriceSetDTO } export const updatePriceSetsStepId = "update-price-sets" export const updatePriceSetsStep = createStep( updatePriceSetsStepId, async (data: UpdatePriceSetsStepInput, { container }) => { + if (!data.selector || !data.update) { + return new StepResponse([], null) + } + const pricingModule = container.resolve( ModuleRegistrationName.PRICING ) diff --git a/packages/core-flows/src/product/steps/get-variant-pricing-link.ts b/packages/core-flows/src/product/steps/get-variant-pricing-link.ts new file mode 100644 index 0000000000..0baa76b66b --- /dev/null +++ b/packages/core-flows/src/product/steps/get-variant-pricing-link.ts @@ -0,0 +1,50 @@ +import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" +import { IProductModuleService } from "@medusajs/types" +import { + ContainerRegistrationKeys, + MedusaError, + arrayDifference, +} from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type StepInput = { + ids: string[] +} + +export const getVariantPricingLinkStepId = "get-variant-pricing-link" +export const getVariantPricingLinkStep = createStep( + getVariantPricingLinkStepId, + async (data: StepInput, { container }) => { + if (!data.ids.length) { + return new StepResponse([]) + } + + const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) + + const linkService = remoteLink.getLinkModule( + Modules.PRODUCT, + "variant_id", + Modules.PRICING, + "price_set_id" + ) + + const existingItems = await linkService.list( + { variant_id: data.ids }, + { select: ["variant_id", "price_set_id"] } + ) + + if (existingItems.length !== data.ids.length) { + const missing = arrayDifference( + data.ids, + existingItems.map((i) => i.variant_id) + ) + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Variants with IDs ${missing.join(", ")} do not have prices associated.` + ) + } + + return new StepResponse(existingItems) + } +) diff --git a/packages/core-flows/src/product/workflows/create-product-variants.ts b/packages/core-flows/src/product/workflows/create-product-variants.ts index 62363ea4ea..58f8747863 100644 --- a/packages/core-flows/src/product/workflows/create-product-variants.ts +++ b/packages/core-flows/src/product/workflows/create-product-variants.ts @@ -34,27 +34,21 @@ export const createProductVariantsWorkflow = createWorkflow( const createdVariants = createProductVariantsStep(variantsWithoutPrices) // Note: We rely on the same order of input and output when creating variants here, make sure that assumption holds - const variantsWithAssociatedPrices = transform( - { input, createdVariants }, - (data) => - data.createdVariants - .map((variant, i) => { - return { - id: variant.id, - prices: data.input.product_variants[i]?.prices, - } - }) - .flat() - .filter((v) => !!v.prices?.length) + const pricesToCreate = transform({ input, createdVariants }, (data) => + data.createdVariants.map((v, i) => { + return { + prices: data.input.product_variants[i]?.prices, + } + }) ) // TODO: From here until the final transform the code is the same as when creating a product, we can probably refactor - const createdPriceSets = createPriceSetsStep(variantsWithAssociatedPrices) + const createdPriceSets = createPriceSetsStep(pricesToCreate) const variantAndPriceSets = transform( - { variantsWithAssociatedPrices, createdPriceSets }, + { createdVariants, createdPriceSets }, (data) => { - return data.variantsWithAssociatedPrices.map((variant, i) => ({ + return data.createdVariants.map((variant, i) => ({ variant: variant, price_set: data.createdPriceSets[i], })) @@ -77,15 +71,12 @@ export const createProductVariantsWorkflow = createWorkflow( return transform( { - createdVariants, variantAndPriceSets, }, (data) => { - return data.createdVariants.map((variant) => ({ - ...variant, - price_set: data.variantAndPriceSets.find( - (v) => v.variant.id === variant.id - )?.price_set, + return data.variantAndPriceSets.map((variantAndPriceSet) => ({ + ...variantAndPriceSet.variant, + ...variantAndPriceSet.price_set, })) } ) diff --git a/packages/core-flows/src/product/workflows/create-products.ts b/packages/core-flows/src/product/workflows/create-products.ts index 54f9a765c2..8bf28884bb 100644 --- a/packages/core-flows/src/product/workflows/create-products.ts +++ b/packages/core-flows/src/product/workflows/create-products.ts @@ -44,16 +44,21 @@ export const createProductsWorkflow = createWorkflow( .map((p, i) => { const inputProduct = data.input.products[i] return p.variants?.map((v, j) => ({ - id: v.id, + ...v, prices: inputProduct?.variants?.[j]?.prices, })) }) .flat() - .filter((v) => !!v.prices?.length) } ) - const createdPriceSets = createPriceSetsStep(variantsWithAssociatedPrices) + const pricesToCreate = transform({ variantsWithAssociatedPrices }, (data) => + data.variantsWithAssociatedPrices.map((v) => ({ + prices: v.prices, + })) + ) + + const createdPriceSets = createPriceSetsStep(pricesToCreate) const variantAndPriceSets = transform( { variantsWithAssociatedPrices, createdPriceSets }, diff --git a/packages/core-flows/src/product/workflows/update-product-variants.ts b/packages/core-flows/src/product/workflows/update-product-variants.ts index de0447eb42..a7e7971ae9 100644 --- a/packages/core-flows/src/product/workflows/update-product-variants.ts +++ b/packages/core-flows/src/product/workflows/update-product-variants.ts @@ -1,10 +1,18 @@ -import { ProductTypes } from "@medusajs/types" -import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { PricingTypes, ProductTypes } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" import { updateProductVariantsStep } from "../steps" +import { updatePriceSetsStep } from "../../pricing" +import { getVariantPricingLinkStep } from "../steps/get-variant-pricing-link" type UpdateProductVariantsStepInput = { selector: ProductTypes.FilterableProductVariantProps - update: ProductTypes.UpdateProductVariantDTO + update: ProductTypes.UpdateProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + } } type WorkflowInput = UpdateProductVariantsStepInput @@ -15,6 +23,72 @@ export const updateProductVariantsWorkflow = createWorkflow( ( input: WorkflowData ): WorkflowData => { - return updateProductVariantsStep(input) + // Passing prices to the product module will fail, we want to keep them for after the variant is updated. + const updateWithoutPrices = transform({ input }, (data) => { + return { + selector: data.input.selector, + update: { + ...data.input.update, + prices: undefined, + }, + } + }) + + const updatedVariants = updateProductVariantsStep(updateWithoutPrices) + + // We don't want to do any pricing updates if the prices didn't change + const variantIds = transform({ input, updatedVariants }, (data) => { + if (!data.input.update.prices) { + return [] + } + + return data.updatedVariants.map((v) => v.id) + }) + + const variantPriceSetLinks = getVariantPricingLinkStep({ + ids: variantIds, + }) + + const pricesToUpdate = transform( + { input, variantPriceSetLinks }, + (data) => { + if (!data.variantPriceSetLinks.length) { + return {} + } + + return { + selector: { + ids: data.variantPriceSetLinks.map((link) => link.price_set_id), + } as PricingTypes.FilterablePriceSetProps, + update: { + prices: data.input.update.prices, + } as PricingTypes.UpdatePriceSetDTO, + } + } + ) + + const updatedPriceSets = updatePriceSetsStep(pricesToUpdate) + + // We want to correctly return the variants with their associated price sets and the prices coming from it + return transform( + { + variantPriceSetLinks, + updatedVariants, + updatedPriceSets, + }, + (data) => { + return data.updatedVariants.map((variant, i) => { + const linkForVariant = data.variantPriceSetLinks.find( + (link) => link.variant_id === variant.id + ) + + const priceSetForVariant = data.updatedPriceSets.find( + (priceSet) => priceSet.id === linkForVariant?.price_set_id + ) + + return { ...variant, price_set: priceSetForVariant } + }) + } + ) } ) diff --git a/packages/medusa/src/api-v2/admin/products/helpers.ts b/packages/medusa/src/api-v2/admin/products/helpers.ts index 81dca40df9..bfc741cf4d 100644 --- a/packages/medusa/src/api-v2/admin/products/helpers.ts +++ b/packages/medusa/src/api-v2/admin/products/helpers.ts @@ -47,6 +47,10 @@ export const remapProduct = (p: ProductDTO) => { } export const remapVariant = (v: ProductVariantDTO) => { + if (!v) { + return v + } + return { ...v, prices: (v as any).price_set?.prices?.map((price) => ({ diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index 2cb02d650a..cc28776e90 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -646,7 +646,8 @@ export class AdminPostProductsProductOptionsOptionReq { // eslint-disable-next-line max-len export class ProductVariantReq extends AdminPostProductsProductVariantsVariantReq { @IsString() - id: string + @IsOptional() + id?: string } export class ProductTagReq { diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 9dc7128341..62af255185 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -1108,7 +1108,7 @@ export default class ProductModuleService< const productData = await this.productService_.upsertWithReplace( normalizedInput, { - relations: ["type", "collection", "images", "tags", "categories"], + relations: ["images", "tags", "categories"], }, sharedContext ) @@ -1164,7 +1164,7 @@ export default class ProductModuleService< const productData = await this.productService_.upsertWithReplace( normalizedInput, { - relations: ["type", "collection", "images", "tags", "categories"], + relations: ["images", "tags", "categories"], }, sharedContext ) @@ -1350,6 +1350,10 @@ export default class ProductModuleService< } ) + if (!variantOptions.length) { + return variant + } + return { ...variant, options: variantOptions, diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index 8f5b63d0ec..ad8318543f 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -673,7 +673,7 @@ export interface ProductOptionValueDTO { * @prop handle - The handles to filter products by. * @prop id - The IDs to filter products by. * @prop tags - Filters on a product's tags. - * @prop categories - Filters on a product's categories. + * @prop category_id - Filters on a product's category_id. * @prop collection_id - Filters a product by its associated collections. */