diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap new file mode 100644 index 0000000000..ea00dae66d --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap @@ -0,0 +1,382 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/products GET /admin/products returns a list of products with child entities 1`] = ` +Array [ + Object { + "collection": Object { + "created_at": Any, + "deleted_at": null, + "handle": "test-collection", + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "title": "Test collection", + "updated_at": Any, + }, + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description", + "discountable": true, + "handle": "test-product", + "height": null, + "hs_code": null, + "id": StringMatching /\\^test-\\*/, + "images": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "updated_at": Any, + "url": "test-image.png", + }, + ], + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "product_id": StringMatching /\\^test-\\*/, + "title": "test-option", + "updated_at": Any, + }, + ], + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "subtitle": null, + "tags": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^tag\\*/, + "metadata": null, + "updated_at": Any, + "value": "123", + }, + ], + "thumbnail": null, + "title": "Test product", + "type": Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "updated_at": Any, + "value": "test-type", + }, + "type_id": "test-type", + "updated_at": Any, + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": "test-barcode", + "created_at": Any, + "deleted_at": null, + "ean": "test-ean", + "height": null, + "hs_code": null, + "id": "test-variant", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku", + "title": "Test variant", + "upc": "test-upc", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": "test-ean2", + "height": null, + "hs_code": null, + "id": "test-variant_2", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 2", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku2", + "title": "Test variant rank (2)", + "upc": "test-upc2", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "allow_backorder": false, + "barcode": "test-barcode 1", + "created_at": Any, + "deleted_at": null, + "ean": "test-ean1", + "height": null, + "hs_code": null, + "id": "test-variant_1", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 1", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku1", + "title": "Test variant rank (1)", + "upc": "test-upc1", + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "weight": null, + "width": null, + }, + Object { + "collection": Object { + "created_at": Any, + "deleted_at": null, + "handle": "test-collection", + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "title": "Test collection", + "updated_at": Any, + }, + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description1", + "discountable": true, + "handle": "test-product1", + "height": null, + "hs_code": null, + "id": StringMatching /\\^test-\\*/, + "images": Array [], + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [], + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "subtitle": null, + "tags": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^tag\\*/, + "metadata": null, + "updated_at": Any, + "value": "123", + }, + ], + "thumbnail": null, + "title": "Test product1", + "type": Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "updated_at": Any, + "value": "test-type", + }, + "type_id": "test-type", + "updated_at": Any, + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": "test-ean4", + "height": null, + "hs_code": null, + "id": "test-variant_4", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 3", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku4", + "title": "Test variant rank (2)", + "upc": "test-upc4", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": "test-ean3", + "height": null, + "hs_code": null, + "id": "test-variant_3", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 3", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku3", + "title": "Test variant rank (2)", + "upc": "test-upc3", + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "weight": null, + "width": null, + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 7b0696e02c..c57ff82ffd 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -1,49 +1,266 @@ -const path = require("path"); +const path = require("path") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") -const adminSeeder = require("../../helpers/admin-seeder"); -const productSeeder = require("../../helpers/product-seeder"); +const adminSeeder = require("../../helpers/admin-seeder") +const productSeeder = require("../../helpers/product-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/products", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: true }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); + const db = useDb() + await db.shutdown() - medusaProcess.kill(); - }); + medusaProcess.kill() + }) + + describe("GET /admin/products", () => { + beforeEach(async () => { + try { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns a list of products with child entities", async () => { + const api = useApi() + + const response = await api + .get("/admin/products", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.data.products).toMatchSnapshot([ + { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + options: [ + { + id: expect.stringMatching(/^test-*/), + product_id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + images: [ + { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + variants: [ + { + id: "test-variant", //expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + product_id: expect.stringMatching(/^test-*/), + prices: [ + { + id: expect.stringMatching(/^test-price*/), + variant_id: expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + options: [ + { + 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), + }, + ], + }, + { + id: "test-variant_2", //expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + product_id: expect.stringMatching(/^test-*/), + prices: [ + { + id: expect.stringMatching(/^test-price*/), + variant_id: expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + options: [ + { + 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), + }, + ], + }, + { + id: "test-variant_1", // expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + product_id: expect.stringMatching(/^test-*/), + prices: [ + { + id: expect.stringMatching(/^test-price*/), + variant_id: expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + options: [ + { + 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), + }, + ], + }, + ], + tags: [ + { + id: expect.stringMatching(/^tag*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + type: { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + collection: { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + profile_id: expect.stringMatching(/^sp_*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + options: [], + variants: [ + { + id: "test-variant_4", //expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + product_id: expect.stringMatching(/^test-*/), + prices: [ + { + id: expect.stringMatching(/^test-price*/), + variant_id: expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + options: [ + { + 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), + }, + ], + }, + { + id: "test-variant_3", //expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + product_id: expect.stringMatching(/^test-*/), + prices: [ + { + id: expect.stringMatching(/^test-price*/), + variant_id: expect.stringMatching(/^test-variant*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + options: [ + { + 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), + }, + ], + }, + ], + tags: [ + { + id: expect.stringMatching(/^tag*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + type: { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + collection: { + id: expect.stringMatching(/^test-*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + profile_id: expect.stringMatching(/^sp_*/), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ]) + }) + }) describe("POST /admin/products", () => { beforeEach(async () => { try { - await productSeeder(dbConnection); - await adminSeeder(dbConnection); + await productSeeder(dbConnection) + await adminSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("creates a product", async () => { - const api = useApi(); + const api = useApi() const payload = { title: "Test product", @@ -61,7 +278,7 @@ describe("/admin/products", () => { options: [{ value: "large" }, { value: "green" }], }, ], - }; + } const response = await api .post("/admin/products", payload, { @@ -70,11 +287,10 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); - - expect(response.status).toEqual(200); + console.log(err) + }) + expect(response.status).toEqual(200) expect(response.data.product).toEqual( expect.objectContaining({ title: "Test product", @@ -133,11 +349,77 @@ describe("/admin/products", () => { }), ], }) - ); - }); + ) + }) + + it("Sets variant ranks when creating a product", async () => { + const api = useApi() + + const payload = { + title: "Test product - 1", + description: "test-product-description 1", + type: { value: "test-type 1" }, + images: ["test-image.png", "test-image-2.png"], + collection_id: "test-collection", + tags: [{ value: "123" }, { value: "456" }], + options: [{ title: "size" }, { title: "color" }], + variants: [ + { + title: "Test variant 1", + inventory_quantity: 10, + prices: [{ currency_code: "usd", amount: 100 }], + options: [{ value: "large" }, { value: "green" }], + }, + { + title: "Test variant 2", + inventory_quantity: 10, + prices: [{ currency_code: "usd", amount: 100 }], + options: [{ value: "large" }, { value: "green" }], + }, + ], + } + + const creationResponse = await api + .post("/admin/products", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(creationResponse.status).toEqual(200) + + const productId = creationResponse.data.product.id + + const response = await api + .get(`/admin/products/${productId}`, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.data.product).toEqual( + expect.objectContaining({ + title: "Test product - 1", + variants: [ + expect.objectContaining({ + title: "Test variant 1", + }), + expect.objectContaining({ + title: "Test variant 2", + }), + ], + }) + ) + }) it("creates a giftcard", async () => { - const api = useApi(); + const api = useApi() const payload = { title: "Test Giftcard", @@ -151,7 +433,7 @@ describe("/admin/products", () => { options: [{ value: "100" }], }, ], - }; + } const response = await api .post("/admin/products", payload, { @@ -160,21 +442,21 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.product).toEqual( expect.objectContaining({ title: "Test Giftcard", discountable: false, }) - ); - }); + ) + }) it("updates a product (update prices, tags, delete collection, delete type, replaces images)", async () => { - const api = useApi(); + const api = useApi() const payload = { collection_id: null, @@ -182,13 +464,19 @@ describe("/admin/products", () => { variants: [ { id: "test-variant", - prices: [{ currency_code: "usd", amount: 100, sale_amount: 75 }], + prices: [ + { + currency_code: "usd", + amount: 100, + sale_amount: 75, + }, + ], }, ], tags: [{ value: "123" }], images: ["test-image-2.png"], type: { value: "test-type-2" }, - }; + } const response = await api .post("/admin/products/test-product", payload, { @@ -197,10 +485,10 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.product).toEqual( expect.objectContaining({ @@ -231,15 +519,69 @@ describe("/admin/products", () => { value: "test-type-2", }), }) - ); - }); + ) + }) + + it("updates a product (variant ordering)", async () => { + const api = useApi() + + const payload = { + collection_id: null, + type: null, + variants: [ + { + id: "test-variant", + }, + { + id: "test-variant_1", + }, + { + id: "test-variant_2", + }, + ], + } + + const response = await api + .post("/admin/products/test-product", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.product).toEqual( + expect.objectContaining({ + title: "Test product", + 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, + }) + ) + }) it("add option", async () => { - const api = useApi(); + const api = useApi() const payload = { title: "should_add", - }; + } const response = await api .post("/admin/products/test-product/options", payload, { @@ -248,41 +590,41 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.product).toEqual( expect.objectContaining({ - options: [ + options: expect.arrayContaining([ expect.objectContaining({ title: "should_add", product_id: "test-product", }), - ], + ]), }) - ); - }); - }); + ) + }) + }) describe("testing for soft-deletion + uniqueness on handles, collection and variant properties", () => { beforeEach(async () => { try { - await productSeeder(dbConnection); - await adminSeeder(dbConnection); + await productSeeder(dbConnection) + await adminSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("successfully deletes a product", async () => { - const api = useApi(); + const api = useApi() const response = await api .delete("/admin/products/test-product", { @@ -291,21 +633,21 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data).toEqual( expect.objectContaining({ id: "test-product", deleted: true, }) - ); - }); + ) + }) it("successfully creates product with soft-deleted product handle", async () => { - const api = useApi(); + const api = useApi() // First we soft-delete the product const response = await api @@ -315,11 +657,11 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.id).toEqual("test-product"); + expect(response.status).toEqual(200) + expect(response.data.id).toEqual("test-product") // Lets try to create a product with same handle as deleted one const payload = { @@ -339,20 +681,20 @@ describe("/admin/products", () => { options: [{ value: "large" }, { value: "green" }], }, ], - }; + } const res = await api.post("/admin/products", payload, { headers: { Authorization: "Bearer test_token", }, - }); + }) - expect(res.status).toEqual(200); - expect(res.data.product.handle).toEqual("test-product"); - }); + expect(res.status).toEqual(200) + expect(res.data.product.handle).toEqual("test-product") + }) it("successfully deletes product collection", async () => { - const api = useApi(); + const api = useApi() // First we soft-delete the product collection const response = await api @@ -362,15 +704,15 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.id).toEqual("test-collection"); - }); + expect(response.status).toEqual(200) + expect(response.data.id).toEqual("test-collection") + }) it("successfully creates soft-deleted product collection", async () => { - const api = useApi(); + const api = useApi() const response = await api .delete("/admin/collections/test-collection", { @@ -379,30 +721,40 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.id).toEqual("test-collection"); + expect(response.status).toEqual(200) + expect(response.data.id).toEqual("test-collection") // Lets try to create a product collection with same handle as deleted one const payload = { title: "Another test collection", handle: "test-collection", - }; + } const res = await api.post("/admin/collections", payload, { headers: { Authorization: "Bearer test_token", }, - }); + }) - expect(res.status).toEqual(200); - expect(res.data.collection.handle).toEqual("test-collection"); - }); + expect(res.status).toEqual(200) + expect(res.data.collection.handle).toEqual("test-collection") + }) it("successfully creates soft-deleted product variant", async () => { - const api = useApi(); + const api = useApi() + + const product = await api + .get("/admin/products/test-product", { + headers: { + Authorization: "bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) const response = await api .delete("/admin/products/test-product/variants/test-variant", { @@ -411,11 +763,11 @@ describe("/admin/products", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.variant_id).toEqual("test-variant"); + expect(response.status).toEqual(200) + expect(response.data.variant_id).toEqual("test-variant") // Lets try to create a product collection with same handle as deleted one const payload = { @@ -430,19 +782,18 @@ describe("/admin/products", () => { amount: 100, }, ], - }; + options: [{ option_id: "test-option", value: "inserted value" }], + } - const res = await api.post( - "/admin/products/test-product/variants", - payload, - { + const res = await api + .post("/admin/products/test-product/variants", payload, { headers: { Authorization: "Bearer test_token", }, - } - ); + }) + .catch((err) => console.log(err)) - expect(res.status).toEqual(200); + expect(res.status).toEqual(200) expect(res.data.product.variants).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -453,7 +804,7 @@ describe("/admin/products", () => { barcode: "test-barcode", }), ]) - ); - }); - }); -}); + ) + }) + }) +}) diff --git a/integration-tests/api/helpers/product-seeder.js b/integration-tests/api/helpers/product-seeder.js index 77fd31a034..cc69255038 100644 --- a/integration-tests/api/helpers/product-seeder.js +++ b/integration-tests/api/helpers/product-seeder.js @@ -2,55 +2,56 @@ const { ProductCollection, ProductTag, ProductType, + ProductOption, Region, Product, ShippingProfile, ProductVariant, Image, -} = require("@medusajs/medusa"); +} = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { - const manager = connection.manager; + const manager = connection.manager const defaultProfile = await manager.findOne(ShippingProfile, { type: "default", - }); + }) const coll = manager.create(ProductCollection, { id: "test-collection", handle: "test-collection", title: "Test collection", - }); + }) - await manager.save(coll); + await manager.save(coll) const tag = manager.create(ProductTag, { id: "tag1", value: "123", - }); + }) - await manager.save(tag); + await manager.save(tag) const type = manager.create(ProductType, { id: "test-type", value: "test-type", - }); + }) - await manager.save(type); + await manager.save(type) const image = manager.create(Image, { id: "test-image", url: "test-image.png", - }); + }) - await manager.save(image); + await manager.save(image) await manager.insert(Region, { id: "test-region", name: "Test Region", currency_code: "usd", tax_rate: 0, - }); + }) const p = manager.create(Product, { id: "test-product", @@ -64,23 +65,138 @@ module.exports = async (connection, data = {}) => { { id: "tag1", value: "123" }, { tag: "tag2", value: "456" }, ], - options: [{ id: "test-option", title: "Default value" }], - }); + }) - p.images = [image]; + p.images = [image] - await manager.save(p); + await manager.save(p) - await manager.insert(ProductVariant, { + await manager.save(ProductOption, { + id: "test-option", + title: "test-option", + product_id: "test-product", + }) + + const variant1 = await manager.create(ProductVariant, { id: "test-variant", inventory_quantity: 10, title: "Test variant", + variant_rank: 0, sku: "test-sku", ean: "test-ean", upc: "test-upc", barcode: "test-barcode", product_id: "test-product", prices: [{ id: "test-price", currency_code: "usd", amount: 100 }], - options: [{ id: "test-variant-option", value: "Default variant" }], - }); -}; + options: [ + { + id: "test-variant-option", + value: "Default variant", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant1) + + const variant2 = await manager.create(ProductVariant, { + id: "test-variant_1", + inventory_quantity: 10, + title: "Test variant rank (1)", + variant_rank: 2, + sku: "test-sku1", + ean: "test-ean1", + upc: "test-upc1", + barcode: "test-barcode 1", + product_id: "test-product", + prices: [{ id: "test-price1", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-1", + value: "Default variant 1", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant2) + + const variant3 = await manager.create(ProductVariant, { + id: "test-variant_2", + inventory_quantity: 10, + title: "Test variant rank (2)", + variant_rank: 1, + sku: "test-sku2", + ean: "test-ean2", + upc: "test-upc2", + product_id: "test-product", + prices: [{ id: "test-price2", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-2", + value: "Default variant 2", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant3) + + const p1 = manager.create(Product, { + id: "test-product1", + handle: "test-product1", + title: "Test product1", + profile_id: defaultProfile.id, + description: "test-product-description1", + collection_id: "test-collection", + type: { id: "test-type", value: "test-type" }, + tags: [ + { id: "tag1", value: "123" }, + { tag: "tag2", value: "456" }, + ], + }) + + await manager.save(p1) + + const variant4 = await manager.create(ProductVariant, { + id: "test-variant_3", + inventory_quantity: 10, + title: "Test variant rank (2)", + variant_rank: 1, + sku: "test-sku3", + ean: "test-ean3", + upc: "test-upc3", + product_id: "test-product1", + prices: [{ id: "test-price3", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-3", + value: "Default variant 3", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant4) + + const variant5 = await manager.create(ProductVariant, { + id: "test-variant_4", + inventory_quantity: 10, + title: "Test variant rank (2)", + variant_rank: 0, + sku: "test-sku4", + ean: "test-ean4", + upc: "test-upc4", + product_id: "test-product1", + prices: [{ id: "test-price4", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-4", + value: "Default variant 3", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant5) +} diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js index c3aa01cddc..25a9012395 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js @@ -1,10 +1,80 @@ import { IdMap } from "medusa-test-utils" import { request } from "../../../../../helpers/test-request" import { ProductServiceMock } from "../../../../../services/__mocks__/product" +import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant" import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile" describe("POST /admin/products", () => { - describe("successful creation", () => { + describe("successful creation with variants", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", "/admin/products", { + payload: { + title: "Test Product with variants", + description: "Test Description", + tags: [{ id: "test", value: "test" }], + handle: "test-product", + options: [{ title: "Test" }], + variants: [ + { + title: "Test", + prices: [ + { + currency_code: "USD", + amount: 100, + }, + ], + options: [ + { + value: "100", + }, + ], + }, + ], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(async () => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("assigns invokes productVariantService with ranked variants", () => { + expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(1) + expect(ProductVariantServiceMock.create).toHaveBeenCalledWith( + IdMap.getId("productWithOptions"), + { + title: "Test", + variant_rank: 0, + prices: [ + { + currency_code: "USD", + amount: 100, + }, + ], + options: [ + { + option_id: IdMap.getId("option1"), + value: "100", + }, + ], + inventory_quantity: 0, + } + ) + }) + }) + + describe("successful creation test", () => { let subject beforeAll(async () => { @@ -14,6 +84,7 @@ describe("POST /admin/products", () => { description: "Test Description", tags: [{ id: "test", value: "test" }], handle: "test-product", + options: [{ title: "Denominations" }], }, adminSession: { jwt: { @@ -40,6 +111,7 @@ describe("POST /admin/products", () => { tags: [{ id: "test", value: "test" }], handle: "test-product", is_giftcard: false, + options: [{ title: "Denominations" }], profile_id: IdMap.getId("default_shipping_profile"), }) }) diff --git a/packages/medusa/src/api/routes/admin/products/create-product.js b/packages/medusa/src/api/routes/admin/products/create-product.js index 0d9b928691..95c5f4b3b7 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.js +++ b/packages/medusa/src/api/routes/admin/products/create-product.js @@ -328,6 +328,8 @@ export default async (req, res) => { .create({ ...value, profile_id: shippingProfile.id }) if (variants) { + for (const [i, variant] of variants.entries()) variant.variant_rank = i + const optionIds = value.options.map( o => newProduct.options.find(newO => newO.title === o.title).id ) @@ -341,6 +343,7 @@ export default async (req, res) => { option_id: optionIds[index], })), } + await productVariantService .withTransaction(manager) .create(newProduct.id, variant) diff --git a/packages/medusa/src/migrations/1631104895519-rank_column_with_default_value.ts b/packages/medusa/src/migrations/1631104895519-rank_column_with_default_value.ts new file mode 100644 index 0000000000..75b501cc38 --- /dev/null +++ b/packages/medusa/src/migrations/1631104895519-rank_column_with_default_value.ts @@ -0,0 +1,23 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class RankColumnWithDefaultValue1631104895519 implements MigrationInterface { + name = 'RankColumnWithDefaultValue1631104895519' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "product_variant" ADD "variant_rank" integer DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "product_option_value" DROP CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01"`); + await queryRunner.query(`ALTER TABLE "product_option_value" ADD CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE cascade ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "money_amount" DROP CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0"`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE cascade ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "product_variant" DROP COLUMN "variant_rank"`); + await queryRunner.query(`ALTER TABLE "product_option_value" DROP CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01"`); + await queryRunner.query(`ALTER TABLE "product_option_value" ADD CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "money_amount" DROP CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0"`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + + } + +} diff --git a/packages/medusa/src/models/money-amount.ts b/packages/medusa/src/models/money-amount.ts index 29440b5083..b4e87b716d 100644 --- a/packages/medusa/src/models/money-amount.ts +++ b/packages/medusa/src/models/money-amount.ts @@ -44,7 +44,11 @@ export class MoneyAmount { @Column({ nullable: true }) variant_id: string - @ManyToOne(() => ProductVariant) + @ManyToOne( + () => ProductVariant, + variant => variant.prices, + { onDelete: "cascade" } + ) @JoinColumn({ name: "variant_id" }) variant: ProductVariant diff --git a/packages/medusa/src/models/product-option-value.ts b/packages/medusa/src/models/product-option-value.ts index 64a390bafe..379ad87591 100644 --- a/packages/medusa/src/models/product-option-value.ts +++ b/packages/medusa/src/models/product-option-value.ts @@ -41,7 +41,8 @@ export class ProductOptionValue { @ManyToOne( () => ProductVariant, - variant => variant.options + variant => variant.options, + { onDelete: "cascade" } ) @JoinColumn({ name: "variant_id" }) variant: ProductVariant diff --git a/packages/medusa/src/models/product-variant.ts b/packages/medusa/src/models/product-variant.ts index a746605e27..94c10fa104 100644 --- a/packages/medusa/src/models/product-variant.ts +++ b/packages/medusa/src/models/product-variant.ts @@ -43,7 +43,7 @@ export class ProductVariant { @OneToMany( () => MoneyAmount, ma => ma.variant, - { cascade: true } + { cascade: true, onDelete: "CASCADE" } ) prices: MoneyAmount[] @@ -63,6 +63,9 @@ export class ProductVariant { @Index({ unique: true, where: "deleted_at IS NOT NULL" }) upc: string + @Column({ nullable: true, default: 0, select:false }) + variant_rank: number + @Column({ type: "int" }) inventory_quantity: number diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index dd1dc83c8f..f565c86751 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -19,7 +19,7 @@ export class ProductRepository extends Repository { } const entitiesIds = entities.map(({ id }) => id) - const groupedRelations = {} + const groupedRelations : { [toplevel: string]: string[]} = {} for (const rel of relations) { const [topLevel] = rel.split(".") if (groupedRelations[topLevel]) { @@ -30,13 +30,33 @@ export class ProductRepository extends Repository { } const entitiesIdsWithRelations = await Promise.all( - Object.entries(groupedRelations).map(([_, rels]) => { - return this.findByIds(entitiesIds, { - select: ["id"], - relations: rels as string[], - }) + Object.entries(groupedRelations).map(([toplevel, rels]) => { + let querybuilder = this.createQueryBuilder("products") + + if (toplevel === "variants") { + querybuilder = querybuilder.leftJoinAndSelect(`products.${toplevel}`, toplevel, "variants.deleted_at IS NULL") + .orderBy({ + "variants.variant_rank": "ASC", + }) + } else { + querybuilder = querybuilder.leftJoinAndSelect(`products.${toplevel}`, toplevel) + } + + for(const rel of rels) { + const [_, rest] = rel.split(".") + if (!rest) { + continue + } + // Regex matches all '.' except the rightmost + querybuilder = querybuilder.leftJoinAndSelect(rel.replace(/\.(?=[^.]*\.)/g,"__"), rel.replace(".", "__")) + } + + return querybuilder + .where("products.deleted_at IS NULL AND products.id IN (:...entitiesIds)", { entitiesIds }) + .getMany(); }) ).then(flatten) + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") diff --git a/packages/medusa/src/services/__mocks__/product.js b/packages/medusa/src/services/__mocks__/product.js index 82fcff22ae..c86cec482e 100644 --- a/packages/medusa/src/services/__mocks__/product.js +++ b/packages/medusa/src/services/__mocks__/product.js @@ -36,7 +36,9 @@ export const ProductServiceMock = { if (data.title === "Test Product") { return Promise.resolve(products.product1) } - + if (data.title === "Test Product with variants") { + return Promise.resolve(products.productWithOptions) + } return Promise.resolve({ ...data }) }), count: jest.fn().mockReturnValue(4), diff --git a/packages/medusa/src/services/__tests__/product-variant.js b/packages/medusa/src/services/__tests__/product-variant.js index 08950022fc..dbd0054eed 100644 --- a/packages/medusa/src/services/__tests__/product-variant.js +++ b/packages/medusa/src/services/__tests__/product-variant.js @@ -148,6 +148,7 @@ describe("ProductVariantService", () => { expect(productVariantRepository.create).toHaveBeenCalledWith({ id: IdMap.getId("v2"), product_id: IdMap.getId("ironman"), + variant_rank: 1, options: [ { id: IdMap.getId("test"), diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index 422bdb42e5..a8fe0463d4 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -11,9 +11,26 @@ const eventBusService = { describe("ProductService", () => { describe("retrieve", () => { const productRepo = MockRepository({ - findOneWithRelations: () => - Promise.resolve({ id: IdMap.getId("ironman") }), + findOneWithRelations: (rels, query) => { + if (query.where.id === "test id with variants") { + return { + id: "test id with variants", + variants: [ + { id: "test_321", title: "Green" }, + { id: "test_123", title: "Blue" }, + ], + } + } + if (query.where.id === "test id one variant") { + return { + id: "test id one variant", + variants: [{ id: "test_123", title: "Blue" }], + } + } + return Promise.resolve({ id: IdMap.getId("ironman") }) + }, }) + const productService = new ProductService({ manager: MockManager, productRepository: productRepo, @@ -37,11 +54,12 @@ describe("ProductService", () => { describe("create", () => { const productRepository = MockRepository({ - create: () => ({ + create: product => ({ id: IdMap.getId("ironman"), title: "Suit", options: [], collection: { id: IdMap.getId("cat"), title: "Suits" }, + variants: product.variants, }), findOneWithRelations: () => ({ id: IdMap.getId("ironman"), @@ -97,6 +115,16 @@ describe("ProductService", () => { options: [], tags: [{ value: "title" }, { value: "title2" }], type: "type-1", + variants: [ + { + id: "test1", + title: "green", + }, + { + id: "test2", + title: "blue", + }, + ], }) expect(eventBusService.emit).toHaveBeenCalledTimes(1) @@ -108,6 +136,16 @@ describe("ProductService", () => { expect(productRepository.create).toHaveBeenCalledTimes(1) expect(productRepository.create).toHaveBeenCalledWith({ title: "Suit", + variants: [ + { + id: "test1", + title: "green", + }, + { + id: "test2", + title: "blue", + }, + ], }) expect(productTagRepository.findOne).toHaveBeenCalledTimes(2) @@ -124,14 +162,30 @@ describe("ProductService", () => { title: "Suit", options: [], tags: [ - { id: "tag-1", value: "title" }, - { id: "tag-2", value: "title2" }, + { + id: "tag-1", + value: "title", + }, + { + id: "tag-2", + value: "title2", + }, ], type_id: "type", collection: { id: IdMap.getId("cat"), title: "Suits", }, + variants: [ + { + id: "test1", + title: "green", + }, + { + id: "test2", + title: "blue", + }, + ], }) }) }) @@ -148,6 +202,15 @@ describe("ProductService", () => { if (query.where.id === "123") { return undefined } + if (query.where.id === "ranking test") { + return Promise.resolve({ + id: "ranking test", + variants: [ + { id: "test_321", title: "Greener", variant_rank: 1 }, + { id: "test_123", title: "Blueer", variant_rank: 0 }, + ], + }) + } return Promise.resolve({ id: IdMap.getId("ironman") }) }, }) @@ -165,7 +228,12 @@ describe("ProductService", () => { withTransaction: function() { return this }, - update: () => Promise.resolve(), + update: (variant, update) => { + if (variant.id) { + return update + } + return Promise.resolve() + }, } const productTagRepository = MockRepository({ @@ -248,6 +316,30 @@ describe("ProductService", () => { }) }) + it("successfully updates variant ranking", async () => { + await productService.update("ranking test", { + variants: [ + { id: "test_321", title: "Greener", variant_rank: 1 }, + { id: "test_123", title: "Blueer", variant_rank: 0 }, + ], + }) + + expect(eventBusService.emit).toHaveBeenCalledTimes(1) + expect(eventBusService.emit).toHaveBeenCalledWith( + "product.updated", + expect.any(Object) + ) + + expect(productRepository.save).toHaveBeenCalledTimes(1) + expect(productRepository.save).toHaveBeenCalledWith({ + id: "ranking test", + variants: [ + { id: "test_321", title: "Greener", variant_rank: 0 }, + { id: "test_123", title: "Blueer", variant_rank: 1 }, + ], + }) + }) + it("successfully updates tags", async () => { await productService.update(IdMap.getId("ironman"), { tags: [ diff --git a/packages/medusa/src/services/product-variant.js b/packages/medusa/src/services/product-variant.js index 8e8a9e8b3c..388d8fad24 100644 --- a/packages/medusa/src/services/product-variant.js +++ b/packages/medusa/src/services/product-variant.js @@ -174,6 +174,10 @@ class ProductVariantService extends BaseService { ) } + if (!rest.variant_rank) { + rest.variant_rank = product.variants.length + } + const toCreate = { ...rest, product_id: product.id, diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 7cacd571f0..45b7375af4 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -410,7 +410,9 @@ class ProductService extends BaseService { } const newVariants = [] - for (const newVariant of variants) { + for (const [i, newVariant] of variants.entries()) { + newVariant.variant_rank = i + if (newVariant.id) { const variant = product.variants.find(v => v.id === newVariant.id)