From 4b09be3a88466187369da15a8ac822188b04b98f Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 23 Jul 2024 11:16:14 +0200 Subject: [PATCH] feat: Add skeleton for supporting product imports in BE (#8232) --- .../batch-jobs/product/ff-product-category.js | 165 ------- .../batch-jobs/product/ff-sales-channel.js | 178 -------- .../__tests__/batch-jobs/product/import.js | 397 ----------------- .../product/product-import-pc-template.csv | 2 - .../product/product-import-ss-template.csv | 2 - .../product/product-import-template.csv | 6 - .../product/admin/product-import.spec.ts | 114 +++++ .../__fixtures__/product-import-template.csv | 6 - .../product/admin/import-products.spec.ts | 418 ------------------ .../src/product/workflows/import-products.ts | 34 ++ .../core-flows/src/product/workflows/index.ts | 1 + .../types/src/http/product/admin/payloads.ts | 1 + .../types/src/http/product/admin/responses.ts | 4 + .../src/workflow/product/import-products.ts | 4 + .../core/types/src/workflow/product/index.ts | 1 + .../src/api/admin/products/import/route.ts | 30 ++ .../src/api/admin/products/middlewares.ts | 19 +- 17 files changed, 204 insertions(+), 1178 deletions(-) delete mode 100644 integration-tests/api/__tests__/batch-jobs/product/ff-product-category.js delete mode 100644 integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js delete mode 100644 integration-tests/api/__tests__/batch-jobs/product/import.js delete mode 100644 integration-tests/api/__tests__/batch-jobs/product/product-import-pc-template.csv delete mode 100644 integration-tests/api/__tests__/batch-jobs/product/product-import-ss-template.csv delete mode 100644 integration-tests/api/__tests__/batch-jobs/product/product-import-template.csv create mode 100644 integration-tests/http/__tests__/product/admin/product-import.spec.ts delete mode 100644 integration-tests/modules/__tests__/product/admin/__fixtures__/product-import-template.csv delete mode 100644 integration-tests/modules/__tests__/product/admin/import-products.spec.ts create mode 100644 packages/core/core-flows/src/product/workflows/import-products.ts create mode 100644 packages/core/types/src/workflow/product/import-products.ts create mode 100644 packages/medusa/src/api/admin/products/import/route.ts diff --git a/integration-tests/api/__tests__/batch-jobs/product/ff-product-category.js b/integration-tests/api/__tests__/batch-jobs/product/ff-product-category.js deleted file mode 100644 index be8091c8fb..0000000000 --- a/integration-tests/api/__tests__/batch-jobs/product/ff-product-category.js +++ /dev/null @@ -1,165 +0,0 @@ -const fs = require("fs") -const path = require("path") - -const { useApi } = require("../../../../environment-helpers/use-api") -const { useDb } = require("../../../../environment-helpers/use-db") - -const adminSeeder = require("../../../../helpers/admin-seeder") -const userSeeder = require("../../../../helpers/user-seeder") -const { simpleProductCategoryFactory } = require("../../../../factories") -const batchJobSeeder = require("../../../../helpers/batch-job-seeder") -const { - simpleProductCollectionFactory, -} = require("../../../../factories/simple-product-collection-factory") - -const startServerWithEnvironment = - require("../../../../environment-helpers/start-server-with-environment").default - -jest.setTimeout(30000) - -function getImportFile() { - return path.resolve( - "__tests__", - "batch-jobs", - "product", - "product-import-pc.csv" - ) -} - -function copyTemplateFile() { - const csvTemplate = path.resolve( - "__tests__", - "batch-jobs", - "product", - "product-import-pc-template.csv" - ) - const destination = getImportFile() - fs.copyFileSync(csvTemplate, destination) -} - -const adminReqConfig = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -function cleanTempData() { - // cleanup tmp ops files - const opsFiles = path.resolve("__tests__", "batch-jobs", "product", "imports") - - fs.rmSync(opsFiles, { recursive: true, force: true }) -} - -describe("Product import - Product Category", () => { - let cat - let dbConnection - let medusaProcess - - const collectionHandle1 = "test-collection1" - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) - - cleanTempData() - - const [process, connection] = await startServerWithEnvironment({ - cwd, - env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, - uploadDir: __dirname, - }) - dbConnection = connection - medusaProcess = process - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - cleanTempData() - medusaProcess.kill() - }) - - beforeEach(async () => { - try { - await batchJobSeeder(dbConnection) - await adminSeeder(dbConnection) - await userSeeder(dbConnection) - - await simpleProductCategoryFactory(dbConnection, { - name: "category", - handle: "import-category-1", - }) - - await simpleProductCollectionFactory(dbConnection, { - handle: collectionHandle1, - }) - } catch (e) { - console.log(e) - throw e - } - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("Import products with an existing product category", async () => { - jest.setTimeout(1000000) - const api = useApi() - - copyTemplateFile() - - const response = await api.post( - "/admin/batch-jobs", - { - type: "product-import", - context: { - fileKey: "product-import-pc.csv", - }, - }, - adminReqConfig - ) - - const batchJobId = response.data.batch_job.id - - let batchJob - let shouldContinuePulling = true - while (shouldContinuePulling) { - const res = await api.get( - `/admin/batch-jobs/${batchJobId}`, - adminReqConfig - ) - - await new Promise((resolve, _) => { - setTimeout(resolve, 1000) - }) - - batchJob = res.data.batch_job - - shouldContinuePulling = !( - batchJob.status === "completed" || batchJob.status === "failed" - ) - } - - expect(batchJob.status).toBe("completed") - - const productsResponse = await api.get( - "/admin/products?expand=categories", - adminReqConfig - ) - - expect(productsResponse.data.count).toBe(1) - expect(productsResponse.data.products).toEqual([ - expect.objectContaining({ - title: "Test product", - handle: "test-product-product-1", - categories: [ - expect.objectContaining({ - handle: "import-category-1", - }), - ], - }), - ]) - }) -}) diff --git a/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js b/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js deleted file mode 100644 index c8ece36836..0000000000 --- a/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js +++ /dev/null @@ -1,178 +0,0 @@ -const fs = require("fs") -const path = require("path") - -const { useApi } = require("../../../../environment-helpers/use-api") -const { useDb } = require("../../../../environment-helpers/use-db") - -const adminSeeder = require("../../../../helpers/admin-seeder") -const userSeeder = require("../../../../helpers/user-seeder") -const { simpleSalesChannelFactory } = require("../../../../factories") -const batchJobSeeder = require("../../../../helpers/batch-job-seeder") -const { - simpleProductCollectionFactory, -} = require("../../../../factories/simple-product-collection-factory") - -const startServerWithEnvironment = - require("../../../../environment-helpers/start-server-with-environment").default - -jest.setTimeout(30000) - -function getImportFile() { - return path.resolve( - "__tests__", - "batch-jobs", - "product", - "product-import-ss.csv" - ) -} - -function copyTemplateFile() { - const csvTemplate = path.resolve( - "__tests__", - "batch-jobs", - "product", - "product-import-ss-template.csv" - ) - const destination = getImportFile() - fs.copyFileSync(csvTemplate, destination) -} - -const adminReqConfig = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -function cleanTempData() { - // cleanup tmp ops files - const opsFiles = path.resolve("__tests__", "batch-jobs", "product", "imports") - - fs.rmSync(opsFiles, { recursive: true, force: true }) -} - -describe("Product import - Sales Channel", () => { - let dbConnection - let medusaProcess - - const collectionHandle1 = "test-collection1" - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) - - cleanTempData() - - const [process, connection] = await startServerWithEnvironment({ - cwd, - env: { MEDUSA_FF_SALES_CHANNELS: true }, - uploadDir: __dirname, - }) - dbConnection = connection - medusaProcess = process - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - cleanTempData() - medusaProcess.kill() - }) - - beforeEach(async () => { - try { - await batchJobSeeder(dbConnection) - await adminSeeder(dbConnection) - await userSeeder(dbConnection) - - await simpleSalesChannelFactory(dbConnection, { - name: "Import Sales Channel 1", - }) - await simpleSalesChannelFactory(dbConnection, { - name: "Import Sales Channel 2", - }) - await simpleProductCollectionFactory(dbConnection, { - handle: collectionHandle1, - }) - } catch (e) { - console.log(e) - throw e - } - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("Import products to an existing sales channel", async () => { - jest.setTimeout(1000000) - const api = useApi() - - copyTemplateFile() - - const response = await api.post( - "/admin/batch-jobs", - { - type: "product-import", - context: { - fileKey: "product-import-ss.csv", - }, - }, - adminReqConfig - ) - - const batchJobId = response.data.batch_job.id - - let batchJob - let shouldContinuePulling = true - while (shouldContinuePulling) { - const res = await api.get( - `/admin/batch-jobs/${batchJobId}`, - adminReqConfig - ) - - await new Promise((resolve, _) => { - setTimeout(resolve, 1000) - }) - - batchJob = res.data.batch_job - - shouldContinuePulling = !( - batchJob.status === "completed" || batchJob.status === "failed" - ) - } - - expect(batchJob.status).toBe("completed") - - const productsResponse = await api.get("/admin/products", adminReqConfig) - - expect(productsResponse.data.count).toBe(1) - expect(productsResponse.data.products).toEqual([ - expect.objectContaining({ - title: "Test product", - description: - "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", - handle: "test-product-product-1", - variants: [ - expect.objectContaining({ - title: "Test variant", - sku: "test-sku-1", - }), - ], - sales_channels: [ - expect.objectContaining({ - name: "Import Sales Channel 1", - is_disabled: false, - }), - expect.objectContaining({ - name: "Import Sales Channel 2", - is_disabled: false, - }), - ], - collection: expect.objectContaining({ - handle: collectionHandle1, - }), - }), - ]) - }) -}) diff --git a/integration-tests/api/__tests__/batch-jobs/product/import.js b/integration-tests/api/__tests__/batch-jobs/product/import.js deleted file mode 100644 index 73e624a674..0000000000 --- a/integration-tests/api/__tests__/batch-jobs/product/import.js +++ /dev/null @@ -1,397 +0,0 @@ -const fs = require("fs") -const path = require("path") - -const setupServer = require("../../../../environment-helpers/setup-server") -const { useApi } = require("../../../../environment-helpers/use-api") -const { initDb, useDb } = require("../../../../environment-helpers/use-db") - -const adminSeeder = require("../../../../helpers/admin-seeder") -const batchJobSeeder = require("../../../../helpers/batch-job-seeder") -const userSeeder = require("../../../../helpers/user-seeder") -const { simpleProductFactory } = require("../../../../factories") -const { - simpleProductCollectionFactory, -} = require("../../../../factories/simple-product-collection-factory") - -const adminReqConfig = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -function getImportFile() { - return path.resolve( - "__tests__", - "batch-jobs", - "product", - "product-import.csv" - ) -} - -function copyTemplateFile() { - const csvTemplate = path.resolve( - "__tests__", - "batch-jobs", - "product", - "product-import-template.csv" - ) - const destination = getImportFile() - fs.copyFileSync(csvTemplate, destination) -} - -jest.setTimeout(1000000) - -function cleanTempData() { - // cleanup tmp ops files - const opsFiles = path.resolve("__tests__", "batch-jobs", "product", "imports") - - fs.rmSync(opsFiles, { recursive: true, force: true }) -} - -describe("Product import batch job", () => { - let medusaProcess - let dbConnection - - const collectionHandle1 = "test-collection1" - const collectionHandle2 = "test-collection2" - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) - dbConnection = await initDb({ cwd }) - - cleanTempData() // cleanup if previous process didn't manage to do it - - medusaProcess = await setupServer({ - cwd, - uploadDir: __dirname, - }) - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - cleanTempData() - - medusaProcess.kill() - }) - - beforeEach(async () => { - await batchJobSeeder(dbConnection) - await adminSeeder(dbConnection) - await userSeeder(dbConnection) - await simpleProductCollectionFactory(dbConnection, [ - { - handle: collectionHandle1, - }, - { - handle: collectionHandle2, - }, - ]) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should import a csv file", async () => { - jest.setTimeout(1000000) - const api = useApi() - - copyTemplateFile() - - const existingProductToBeUpdated = await simpleProductFactory( - dbConnection, - { - id: "existing-product-id", - title: "Test product", - options: [{ id: "opt-1-id", title: "Size" }], - variants: [ - { - id: "existing-variant-id", - title: "Initial tile", - sku: "test-sku-4", - barde: "test-barcode-4", - options: [ - { - option_id: "opt-1-id", - value: "Large", - }, - ], - }, - ], - } - ) - - const response = await api.post( - "/admin/batch-jobs", - { - type: "product-import", - context: { - fileKey: "product-import.csv", - }, - }, - adminReqConfig - ) - - const batchJobId = response.data.batch_job.id - - expect(batchJobId).toBeTruthy() - - // Pull to check the status until it is completed - let batchJob - let shouldContinuePulling = true - while (shouldContinuePulling) { - const res = await api.get( - `/admin/batch-jobs/${batchJobId}`, - adminReqConfig - ) - - await new Promise((resolve, _) => { - setTimeout(resolve, 1000) - }) - - batchJob = res.data.batch_job - - shouldContinuePulling = !( - batchJob.status === "completed" || batchJob.status === "failed" - ) - } - - expect(batchJob.status).toBe("completed") - - const productsResponse = await api.get("/admin/products", adminReqConfig) - expect(productsResponse.data.count).toBe(3) - expect(productsResponse.data.products).toEqual( - expect.arrayContaining([ - // NEW PRODUCT - expect.objectContaining({ - title: "Test product", - description: - "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", - handle: "test-product-product-1", - is_giftcard: false, - status: "draft", - thumbnail: "test-image.png", - variants: [ - // NEW VARIANT - expect.objectContaining({ - title: "Test variant", - sku: "test-sku-1", - barcode: "test-barcode-1", - ean: null, - upc: null, - inventory_quantity: 10, - prices: expect.arrayContaining([ - expect.objectContaining({ - currency_code: "eur", - amount: 100, - region_id: "region-product-import-0", - }), - expect.objectContaining({ - currency_code: "usd", - amount: 110, - }), - expect.objectContaining({ - currency_code: "dkk", - amount: 130, - region_id: "region-product-import-1", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - value: "option 1 value red", - }), - expect.objectContaining({ - value: "option 2 value 1", - }), - ]), - }), - ], - type: null, - images: expect.arrayContaining([ - expect.objectContaining({ - url: "test-image.png", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - title: "test-option-1", - }), - expect.objectContaining({ - title: "test-option-2", - }), - ]), - tags: expect.arrayContaining([ - expect.objectContaining({ - value: "123_1", - }), - ]), - collection: expect.objectContaining({ - handle: collectionHandle1, - }), - }), - expect.objectContaining({ - title: "Test product", - description: - "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", - handle: "test-product-product-1-1", - is_giftcard: false, - status: "draft", - thumbnail: "test-image.png", - variants: [ - // NEW VARIANT - expect.objectContaining({ - title: "Test variant", - sku: "test-sku-1-1", - barcode: "test-barcode-1-1", - ean: null, - upc: null, - inventory_quantity: 10, - prices: expect.arrayContaining([ - expect.objectContaining({ - currency_code: "eur", - amount: 100, - region_id: "region-product-import-0", - }), - expect.objectContaining({ - currency_code: "usd", - amount: 110, - }), - expect.objectContaining({ - currency_code: "dkk", - amount: 130, - region_id: "region-product-import-1", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - value: "option 1 value red", - }), - expect.objectContaining({ - value: "option 2 value 1", - }), - ]), - }), - ], - type: null, - images: expect.arrayContaining([ - expect.objectContaining({ - url: "test-image.png", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - title: "test-option-1", - }), - expect.objectContaining({ - title: "test-option-2", - }), - ]), - tags: [], - collection: expect.objectContaining({ - handle: collectionHandle1, - }), - }), - // UPDATED PRODUCT - expect.objectContaining({ - id: existingProductToBeUpdated.id, - title: "Test product", - description: "test-product-description", - handle: "test-product-product-2", - is_giftcard: false, - status: "draft", - thumbnail: "test-image.png", - profile_id: expect.any(String), - variants: expect.arrayContaining([ - // UPDATED VARIANT - expect.objectContaining({ - id: "existing-variant-id", - title: "Test variant changed", - sku: "test-sku-4", - barcode: "test-barcode-4", - options: [ - expect.objectContaining({ - value: "Large", - option_id: "opt-1-id", - }), - ], - }), - // CREATED VARIANT - expect.objectContaining({ - title: "Test variant", - product_id: existingProductToBeUpdated.id, - sku: "test-sku-2", - barcode: "test-barcode-2", - ean: null, - upc: null, - inventory_quantity: 10, - allow_backorder: false, - manage_inventory: true, - prices: [ - expect.objectContaining({ - currency_code: "dkk", - amount: 110, - region_id: "region-product-import-2", - }), - ], - options: [ - expect.objectContaining({ - value: "Small", - option_id: "opt-1-id", - }), - ], - }), - // CREATED VARIANT - expect.objectContaining({ - title: "Test variant", - product_id: existingProductToBeUpdated.id, - sku: "test-sku-3", - barcode: "test-barcode-3", - ean: null, - upc: null, - inventory_quantity: 10, - allow_backorder: false, - manage_inventory: true, - prices: [ - expect.objectContaining({ - currency_code: "usd", - amount: 120, - region_id: null, - }), - ], - options: [ - expect.objectContaining({ - value: "Medium", - option_id: "opt-1-id", - }), - ], - }), - ]), - images: [ - expect.objectContaining({ - url: "test-image.png", - }), - ], - options: [ - expect.objectContaining({ - product_id: existingProductToBeUpdated.id, - id: "opt-1-id", - title: "Size", - }), - ], - type: expect.objectContaining({ value: "test-type" }), - tags: [ - expect.objectContaining({ - value: "123", - }), - ], - collection: expect.objectContaining({ - handle: collectionHandle2, - }), - }), - ]) - ) - }) -}) diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import-pc-template.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import-pc-template.csv deleted file mode 100644 index 6b5391cc7e..0000000000 --- a/integration-tests/api/__tests__/batch-jobs/product/product-import-pc-template.csv +++ /dev/null @@ -1,2 +0,0 @@ -Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url,Product Category 1 Handle -,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png,import-category-1 diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import-ss-template.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import-ss-template.csv deleted file mode 100644 index cf0c650a00..0000000000 --- a/integration-tests/api/__tests__/batch-jobs/product/product-import-ss-template.csv +++ /dev/null @@ -1,2 +0,0 @@ -Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url,Sales Channel 1 Name,Sales Channel 2 Name,Sales Channel 1 Id,Sales Channel 2 Id -,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png,Import Sales Channel 1,Import Sales Channel 2,, \ No newline at end of file diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import-template.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import-template.csv deleted file mode 100644 index 072a23baeb..0000000000 --- a/integration-tests/api/__tests__/batch-jobs/product/product-import-template.csv +++ /dev/null @@ -1,6 +0,0 @@ -Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url -,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png -,test-product-product-1-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,,TRUE,,,Test variant,test-sku-1-1,test-barcode-1-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,test-image.png,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,Size,Small,,,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,Size,Medium,,,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,existing-variant-id,Test variant changed,test-sku-4,test-barcode-4,10,FALSE,TRUE,,,,,,,,,,,,,Size,Large,,,test-image.png \ No newline at end of file diff --git a/integration-tests/http/__tests__/product/admin/product-import.spec.ts b/integration-tests/http/__tests__/product/admin/product-import.spec.ts new file mode 100644 index 0000000000..d17c0dfc68 --- /dev/null +++ b/integration-tests/http/__tests__/product/admin/product-import.spec.ts @@ -0,0 +1,114 @@ +import { IEventBusModuleService } from "@medusajs/types" +import { TestEventUtils, medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import FormData from "form-data" +import fs from "fs/promises" +import path from "path" +import { ModuleRegistrationName } from "@medusajs/utils" +import { getProductFixture } from "../../../../helpers/fixtures" + +jest.setTimeout(50000) + +const getUploadReq = (file: { name: string; content: string }) => { + const form = new FormData() + form.append("file", Buffer.from(file.content), file.name) + return { + form, + meta: { + headers: { + ...adminHeaders.headers, + ...form.getHeaders(), + }, + }, + } +} + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + let baseCollection + let baseType + let baseProduct + + let eventBus: IEventBusModuleService + beforeAll(async () => { + eventBus = getContainer().resolve(ModuleRegistrationName.EVENT_BUS) + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, getContainer()) + baseCollection = ( + await api.post( + "/admin/collections", + { title: "base-collection" }, + adminHeaders + ) + ).data.collection + + baseType = ( + await api.post( + "/admin/product-types", + { value: "test-type" }, + adminHeaders + ) + ).data.product_type + + baseProduct = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Base product", + type_id: baseType.id, + }), + adminHeaders + ) + ).data.product + }) + + afterEach(() => { + ;(eventBus as any).eventEmitter_.removeAllListeners() + }) + + describe("POST /admin/products/export", () => { + it("should import a products CSV file", async () => { + const subscriberExecution = TestEventUtils.waitSubscribersExecution( + "notification.notification.created", + eventBus + ) + + let fileContent = await fs.readFile( + path.join(__dirname, "__fixtures__", "exported-products.csv"), + { encoding: "utf-8" } + ) + + const { form, meta } = getUploadReq({ + name: "test.csv", + content: fileContent, + }) + + // BREAKING: The batch endpoints moved to the domain routes (admin/batch-jobs -> /admin/products/import). The payload and response changed as well. + const batchJobRes = await api.post("/admin/products/import", form, meta) + + const workflowId = batchJobRes.data.workflow_id + expect(workflowId).toBeTruthy() + + await subscriberExecution + const notifications = ( + await api.get("/admin/notifications", adminHeaders) + ).data.notifications + + expect(notifications.length).toBe(1) + expect(notifications[0]).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + title: "Product import", + description: `Product import of file test.csv completed successfully!`, + }), + }) + ) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/product/admin/__fixtures__/product-import-template.csv b/integration-tests/modules/__tests__/product/admin/__fixtures__/product-import-template.csv deleted file mode 100644 index 072a23baeb..0000000000 --- a/integration-tests/modules/__tests__/product/admin/__fixtures__/product-import-template.csv +++ /dev/null @@ -1,6 +0,0 @@ -Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url -,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png -,test-product-product-1-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,,TRUE,,,Test variant,test-sku-1-1,test-barcode-1-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,test-image.png,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,Size,Small,,,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,Size,Medium,,,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,existing-variant-id,Test variant changed,test-sku-4,test-barcode-4,10,FALSE,TRUE,,,,,,,,,,,,,Size,Large,,,test-image.png \ No newline at end of file diff --git a/integration-tests/modules/__tests__/product/admin/import-products.spec.ts b/integration-tests/modules/__tests__/product/admin/import-products.spec.ts deleted file mode 100644 index 28e1fc0273..0000000000 --- a/integration-tests/modules/__tests__/product/admin/import-products.spec.ts +++ /dev/null @@ -1,418 +0,0 @@ -import fs from "fs" -import path from "path" - -import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" -import { useApi } from "../../../../environment-helpers/use-api" -import { getContainer } from "../../../../environment-helpers/use-container" -import { initDb, useDb } from "../../../../environment-helpers/use-db" -import { simpleProductFactory } from "../../../../factories" -import { simpleProductCollectionFactory } from "../../../../factories/simple-product-collection-factory" -import batchJobSeeder from "../../../../helpers/batch-job-seeder" -import { - adminHeaders, - createAdminUser, -} from "../../../../helpers/create-admin-user" - -const setupServer = require("../../../../environment-helpers/setup-server") -const userSeeder = require("../../../../helpers/user-seeder") - -const adminReqConfig = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -function getImportFile() { - return path.resolve( - "__tests__", - "product", - "admin", - "__fixtures__", - "product-import.csv" - ) -} - -function copyTemplateFile() { - const csvTemplate = path.resolve( - "__tests__", - "product", - "admin", - "__fixtures__", - "product-import-template.csv" - ) - const destination = getImportFile() - - fs.copyFileSync(csvTemplate, destination) -} - -jest.setTimeout(1000000) - -function cleanTempData() { - // cleanup tmp ops files - const opsFiles = path.resolve("__tests__", "product", "admin", "imports") - - fs.rmSync(opsFiles, { recursive: true, force: true }) -} - -const env: Record = { - MEDUSA_FF_MEDUSA_V2: true, -} - -// TODO SEE to use new test runner medusaIntegrationTestRunner({ -// env, -// testSuite: ({ dbConnection, getContainer, api }) => {}) - -describe.skip("Product import batch job", () => { - let dbConnection - let shutdownServer - let medusaProcess - - const collectionHandle1 = "test-collection1" - const collectionHandle2 = "test-collection2" - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) - env.UPLOAD_DIR = __dirname - - cleanTempData() // cleanup if previous process didn't manage to do it - - dbConnection = await initDb({ cwd, env } as any) - shutdownServer = await startBootstrapApp({ cwd, env }) - medusaProcess = await setupServer({ - cwd, - uploadDir: __dirname, - env, - verbose: true, - }) - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - cleanTempData() - - await medusaProcess.kill() - await shutdownServer() - }) - - beforeEach(async () => { - const container = getContainer() - await batchJobSeeder(dbConnection) - await createAdminUser(dbConnection, adminHeaders, container) - await userSeeder(dbConnection) - await simpleProductCollectionFactory(dbConnection, [ - { - handle: collectionHandle1, - }, - { - handle: collectionHandle2, - }, - ]) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should import a csv file", async () => { - jest.setTimeout(1000000) - const api = useApi() - copyTemplateFile() - - const existingProductToBeUpdated = await simpleProductFactory( - dbConnection, - { - id: "existing-product-id", - title: "Test product", - options: [{ id: "opt-1-id", title: "Size" }], - variants: [ - { - id: "existing-variant-id", - title: "Initial tile", - sku: "test-sku-4", - options: [ - { - option_id: "opt-1-id", - value: "Large", - }, - ], - }, - ], - } - ) - - const response = await api.post( - "/admin/batch-jobs", - { - type: "product-import", - context: { - fileKey: "product-import.csv", - }, - }, - adminReqConfig - ) - - const batchJobId = response.data.batch_job.id - - expect(batchJobId).toBeTruthy() - - // Pull to check the status until it is completed - let batchJob - let shouldContinuePulling = true - - while (shouldContinuePulling) { - const res = await api.get( - `/admin/batch-jobs/${batchJobId}`, - adminReqConfig - ) - - await new Promise((resolve, _) => { - setTimeout(resolve, 1000) - }) - - batchJob = res.data.batch_job - - shouldContinuePulling = !( - batchJob.status === "completed" || batchJob.status === "failed" - ) - } - - expect(batchJob.status).toBe("completed") - - const productsResponse = await api.get("/admin/products", adminReqConfig) - expect(productsResponse.data.count).toBe(3) - - expect(productsResponse.data.products).toEqual( - expect.arrayContaining([ - // NEW PRODUCT - expect.objectContaining({ - title: "Test product", - description: - "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", - handle: "test-product-product-1", - is_giftcard: false, - status: "draft", - thumbnail: "test-image.png", - variants: [ - // NEW VARIANT - expect.objectContaining({ - title: "Test variant", - sku: "test-sku-1", - barcode: "test-barcode-1", - ean: null, - upc: null, - // inventory_quantity: 10, - prices: expect.arrayContaining([ - expect.objectContaining({ - currency_code: "eur", - amount: 100, - region_id: "region-product-import-0", - }), - expect.objectContaining({ - currency_code: "usd", - amount: 110, - }), - expect.objectContaining({ - currency_code: "dkk", - amount: 130, - region_id: "region-product-import-1", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - value: "option 1 value red", - }), - expect.objectContaining({ - value: "option 2 value 1", - }), - ]), - }), - ], - type: null, - images: expect.arrayContaining([ - expect.objectContaining({ - url: "test-image.png", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - title: "test-option-1", - }), - expect.objectContaining({ - title: "test-option-2", - }), - ]), - tags: expect.arrayContaining([ - expect.objectContaining({ - value: "123_1", - }), - ]), - collection: expect.objectContaining({ - handle: collectionHandle1, - }), - }), - expect.objectContaining({ - title: "Test product", - description: - "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", - handle: "test-product-product-1-1", - is_giftcard: false, - status: "draft", - thumbnail: "test-image.png", - variants: [ - // NEW VARIANT - expect.objectContaining({ - title: "Test variant", - sku: "test-sku-1-1", - barcode: "test-barcode-1-1", - ean: null, - upc: null, - // inventory_quantity: 10, - prices: expect.arrayContaining([ - expect.objectContaining({ - currency_code: "eur", - amount: 100, - region_id: "region-product-import-0", - }), - expect.objectContaining({ - currency_code: "usd", - amount: 110, - }), - expect.objectContaining({ - currency_code: "dkk", - amount: 130, - region_id: "region-product-import-1", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - value: "option 1 value red", - }), - expect.objectContaining({ - value: "option 2 value 1", - }), - ]), - }), - ], - type: null, - images: expect.arrayContaining([ - expect.objectContaining({ - url: "test-image.png", - }), - ]), - options: expect.arrayContaining([ - expect.objectContaining({ - title: "test-option-1", - }), - expect.objectContaining({ - title: "test-option-2", - }), - ]), - tags: [], - collection: expect.objectContaining({ - handle: collectionHandle1, - }), - }), - // // UPDATED PRODUCT - expect.objectContaining({ - id: existingProductToBeUpdated?.id, - title: "Test product", - description: "test-product-description", - handle: "test-product-product-2", - is_giftcard: false, - status: "draft", - thumbnail: "test-image.png", - profile_id: expect.any(String), - variants: expect.arrayContaining([ - // UPDATED VARIANT - expect.objectContaining({ - id: "existing-variant-id", - title: "Test variant changed", - sku: "test-sku-4", - barcode: "test-barcode-4", - options: [ - expect.objectContaining({ - value: "Large", - option_id: "opt-1-id", - }), - ], - }), - // CREATED VARIANT - expect.objectContaining({ - title: "Test variant", - product_id: existingProductToBeUpdated.id, - sku: "test-sku-2", - barcode: "test-barcode-2", - ean: null, - upc: null, - // inventory_quantity: 10, - allow_backorder: false, - manage_inventory: true, - prices: [ - expect.objectContaining({ - currency_code: "dkk", - amount: 110, - region_id: "region-product-import-2", - }), - ], - options: [ - expect.objectContaining({ - value: "Small", - option_id: "opt-1-id", - }), - ], - }), - // CREATED VARIANT - expect.objectContaining({ - title: "Test variant", - product_id: existingProductToBeUpdated.id, - sku: "test-sku-3", - barcode: "test-barcode-3", - ean: null, - upc: null, - // inventory_quantity: 10, - allow_backorder: false, - manage_inventory: true, - prices: [ - expect.objectContaining({ - currency_code: "usd", - amount: 120, - region_id: null, - }), - ], - options: [ - expect.objectContaining({ - value: "Medium", - option_id: "opt-1-id", - }), - ], - }), - ]), - images: [ - expect.objectContaining({ - url: "test-image.png", - }), - ], - options: [ - expect.objectContaining({ - product_id: existingProductToBeUpdated.id, - id: "opt-1-id", - title: "Size", - }), - ], - type: expect.objectContaining({ value: "test-type" }), - tags: [ - expect.objectContaining({ - value: "123", - }), - ], - collection: expect.objectContaining({ - handle: collectionHandle2, - }), - }), - ]) - ) - }) -}) diff --git a/packages/core/core-flows/src/product/workflows/import-products.ts b/packages/core/core-flows/src/product/workflows/import-products.ts new file mode 100644 index 0000000000..cf724cc954 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/import-products.ts @@ -0,0 +1,34 @@ +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { WorkflowTypes } from "@medusajs/types" +import { sendNotificationsStep } from "../../notification" + +export const importProductsWorkflowId = "import-products" +export const importProductsWorkflow = createWorkflow( + importProductsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + // validateImportCsvStep(input.fileContent) + + const notifications = transform({ input }, (data) => { + return [ + { + // We don't need the recipient here for now, but if we want to push feed notifications to a specific user we could add it. + to: "", + channel: "feed", + template: "admin-ui", + data: { + title: "Product import", + description: `Product import of file ${data.input.filename} completed successfully!`, + }, + }, + ] + }) + + sendNotificationsStep(notifications) + } +) diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index c30d81d8c9..52ae012987 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -21,3 +21,4 @@ export * from "./update-product-tags" export * from "./update-product-variants" export * from "./update-products" export * from "./export-products" +export * from "./import-products" diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index c2ce364103..9ae597950b 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -2,6 +2,7 @@ import { BatchMethodRequest } from "../../../common" import { ProductStatus } from "../common" export interface AdminExportProductRequest {} +export interface AdminImportProductRequest {} export interface AdminBatchProductRequest extends BatchMethodRequest {} diff --git a/packages/core/types/src/http/product/admin/responses.ts b/packages/core/types/src/http/product/admin/responses.ts index 9a13789489..096f5f900e 100644 --- a/packages/core/types/src/http/product/admin/responses.ts +++ b/packages/core/types/src/http/product/admin/responses.ts @@ -35,6 +35,10 @@ export interface AdminExportProductResponse { workflow_id: string } +export interface AdminImportProductResponse { + workflow_id: string +} + export interface AdminBatchProductVariantResponse extends BatchMethodResponse {} diff --git a/packages/core/types/src/workflow/product/import-products.ts b/packages/core/types/src/workflow/product/import-products.ts new file mode 100644 index 0000000000..e184cfa206 --- /dev/null +++ b/packages/core/types/src/workflow/product/import-products.ts @@ -0,0 +1,4 @@ +export interface ImportProductsDTO { + fileContent: string + filename: string +} diff --git a/packages/core/types/src/workflow/product/index.ts b/packages/core/types/src/workflow/product/index.ts index ec58aff9e2..1c560ed22f 100644 --- a/packages/core/types/src/workflow/product/index.ts +++ b/packages/core/types/src/workflow/product/index.ts @@ -3,3 +3,4 @@ export * from "./create-products" export * from "./update-product-variants" export * from "./update-products" export * from "./export-products" +export * from "./import-products" diff --git a/packages/medusa/src/api/admin/products/import/route.ts b/packages/medusa/src/api/admin/products/import/route.ts new file mode 100644 index 0000000000..66f4dbd42b --- /dev/null +++ b/packages/medusa/src/api/admin/products/import/route.ts @@ -0,0 +1,30 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" +import { HttpTypes } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" +import { importProductsWorkflow } from "@medusajs/core-flows" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const input = req.file as Express.Multer.File + + if (!input) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "No file was uploaded for importing" + ) + } + + const { transaction } = await importProductsWorkflow(req.scope).run({ + input: { + filename: input.originalname, + fileContent: input.buffer.toString("utf-8"), + }, + }) + + res.status(202).json({ workflow_id: transaction.transactionId }) +} diff --git a/packages/medusa/src/api/admin/products/middlewares.ts b/packages/medusa/src/api/admin/products/middlewares.ts index 5e80ceef8a..468991772c 100644 --- a/packages/medusa/src/api/admin/products/middlewares.ts +++ b/packages/medusa/src/api/admin/products/middlewares.ts @@ -28,6 +28,12 @@ import { AdminUpdateProductVariant, AdminUpdateVariantInventoryItem, } from "./validators" +import multer from "multer" + +// TODO: For now we keep the files in memory, as that's how they get passed to the workflows +// This will need revisiting once we are closer to prod-ready v2, since with workflows and potentially +// services on other machines using streams is not as simple as it used to be. +const upload = multer({ storage: multer.memoryStorage() }) export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -81,12 +87,17 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/products/import", + middlewares: [upload.single("file")], + }, { method: ["GET"], matcher: "/admin/products/:id", middlewares: [ unlessPath( - /.*\/products\/(batch|export)/, + /.*\/products\/(batch|export|import)/, validateAndTransformQuery( AdminGetProductParams, QueryConfig.retrieveProductQueryConfig @@ -99,11 +110,11 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/products/:id", middlewares: [ unlessPath( - /.*\/products\/(batch|export)/, + /.*\/products\/(batch|export|import)/, validateAndTransformBody(AdminUpdateProduct) ), unlessPath( - /.*\/products\/(batch|export)/, + /.*\/products\/(batch|export|import)/, validateAndTransformQuery( AdminGetProductParams, QueryConfig.retrieveProductQueryConfig @@ -116,7 +127,7 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/products/:id", middlewares: [ unlessPath( - /.*\/products\/(batch|export)/, + /.*\/products\/(batch|export|import)/, validateAndTransformQuery( AdminGetProductParams, QueryConfig.retrieveProductQueryConfig