feat: define validators and use normalize-products step (#12473)

Fixes: FRMW-2965

In this PR we replace/remove the existing step to normalize a CSV file with the newly written CSV normalizer and also we validate the file contents further using a Zod schema.

I have duplicated the schema for now. But it is makes sense to re-use the schema for CSV validating and `/admin/products/batch`, then I can keep one source of truth under utils and re-export it. WDYT?

**Screenshots of some errors after validating the file strictly**

![CleanShot 2025-05-15 at 16 36 46@2x](https://github.com/user-attachments/assets/c7fa424f-b947-4898-9b94-47c48617c129)

![CleanShot 2025-05-15 at 16 36 34@2x](https://github.com/user-attachments/assets/0fefef79-148b-4eeb-8ef0-3077e8063ea8)
This commit is contained in:
Harminder Virk
2025-05-16 14:07:25 +05:30
committed by GitHub
parent 2affc0d7d9
commit e149a99886
23 changed files with 1124 additions and 862 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/core-flows": patch
"@medusajs/utils": patch
"integration-tests-http": patch
---
feat: define validators and use normalize-products step

View File

@@ -1,5 +1,5 @@
Product Id,Product Handle,Product Title,Product Status,Product Description,Product Subtitle,Product External Id,Product Thumbnail,Product Collection Id,Product Type Id,Product Category 1,Product Created At,Product Deleted At,Product Discountable,Product Height,Product Hs Code,Product Image 1,Product Image 2,Product Is Giftcard,Product Length,Product Material,Product Mid Code,Product Origin Country,Product Tag 1,Product Tag 2,Product Updated At,Product Weight,Product Width,Variant Id,Variant Title,Variant Sku,Variant Upc,Variant Ean,Variant Hs Code,Variant Mid Code,Variant Manage Inventory,Variant Allow Backorder,Variant Barcode,Variant Created At,Variant Deleted At,Variant Height,Variant Length,Variant Material,Variant Metadata,Variant Option 1 Name,Variant Option 1 Value,Variant Option 2 Name,Variant Option 2 Value,Variant Origin Country,Variant Price DKK,Variant Price EUR,Variant Price USD,Variant Product Id,Variant Updated At,Variant Variant Rank,Variant Weight,Variant Width,Shipping Profile Id
prod_01J44RRKH4HH2SANJ0S05YM853,base-product,Base product,draft,"test-product-description
test line 2",,,test-image.png,pcol_01J44RRKG4P1RZ3CKAMBS760TD,ptyp_01J44RRKGHPQMJ1CRMW7MEN3P1,pcat_01J44RRKGS2N86A8V37ZJD877K,2024-07-31T16:07:55.681Z,,true,,,test-image.png,test-image-2.png,false,,,,,123,456,2024-07-31T16:07:55.681Z,,,variant_01J44RRKHRQ7WJ902X4936GEK7,Test variant,,,,,,true,false,,2024-07-31T16:07:55.704Z,,,,,,size,large,color,green,,30,45,100,prod_01J44RRKH4HH2SANJ0S05YM853,2024-07-31T16:07:55.704Z,0,,,import-shipping-profile
test line 2",,,test-image.png,pcol_01J44RRKG4P1RZ3CKAMBS760TD,ptyp_01J44RRKGHPQMJ1CRMW7MEN3P1,pcat_01J44RRKGS2N86A8V37ZJD877K,2024-07-31T16:07:55.681Z,,true,,,test-image.png,test-image-2.png,false,,,,,tag-123,tag-456,2024-07-31T16:07:55.681Z,,,variant_01J44RRKHRQ7WJ902X4936GEK7,Test variant,,,,,,true,false,,2024-07-31T16:07:55.704Z,,,,,,size,large,color,green,,30,45,100,prod_01J44RRKH4HH2SANJ0S05YM853,2024-07-31T16:07:55.704Z,0,,,import-shipping-profile
prod_01J44RRKH4HH2SANJ0S05YM853,base-product,Base product,draft,"test-product-description
test line 2",,,test-image.png,pcol_01J44RRKG4P1RZ3CKAMBS760TD,ptyp_01J44RRKGHPQMJ1CRMW7MEN3P1,pcat_01J44RRKGS2N86A8V37ZJD877K,2024-07-31T16:07:55.681Z,,true,,,test-image.png,test-image-2.png,false,,,,,123,456,2024-07-31T16:07:55.681Z,,,variant_01J44RRKHRAYY7Q7NTXNNMDA1S,Test variant 2,,,,,,true,false,,2024-07-31T16:07:55.704Z,,,,,,size,small,color,green,,50,65,200,prod_01J44RRKH4HH2SANJ0S05YM853,2024-07-31T16:07:55.704Z,0,,,import-shipping-profile
test line 2",,,test-image.png,pcol_01J44RRKG4P1RZ3CKAMBS760TD,ptyp_01J44RRKGHPQMJ1CRMW7MEN3P1,pcat_01J44RRKGS2N86A8V37ZJD877K,2024-07-31T16:07:55.681Z,,true,,,test-image.png,test-image-2.png,false,,,,,tag-123,tag-456,2024-07-31T16:07:55.681Z,,,variant_01J44RRKHRAYY7Q7NTXNNMDA1S,Test variant 2,,,,,,true,false,,2024-07-31T16:07:55.704Z,,,,,,size,small,color,green,,50,65,200,prod_01J44RRKH4HH2SANJ0S05YM853,2024-07-31T16:07:55.704Z,0,,,import-shipping-profile
1 Product Id Product Handle Product Title Product Status Product Description Product Subtitle Product External Id Product Thumbnail Product Collection Id Product Type Id Product Category 1 Product Created At Product Deleted At Product Discountable Product Height Product Hs Code Product Image 1 Product Image 2 Product Is Giftcard Product Length Product Material Product Mid Code Product Origin Country Product Tag 1 Product Tag 2 Product Updated At Product Weight Product Width Variant Id Variant Title Variant Sku Variant Upc Variant Ean Variant Hs Code Variant Mid Code Variant Manage Inventory Variant Allow Backorder Variant Barcode Variant Created At Variant Deleted At Variant Height Variant Length Variant Material Variant Metadata Variant Option 1 Name Variant Option 1 Value Variant Option 2 Name Variant Option 2 Value Variant Origin Country Variant Price DKK Variant Price EUR Variant Price USD Variant Product Id Variant Updated At Variant Variant Rank Variant Weight Variant Width Shipping Profile Id
2 prod_01J44RRKH4HH2SANJ0S05YM853 base-product Base product draft test-product-description test line 2 test-image.png pcol_01J44RRKG4P1RZ3CKAMBS760TD ptyp_01J44RRKGHPQMJ1CRMW7MEN3P1 pcat_01J44RRKGS2N86A8V37ZJD877K 2024-07-31T16:07:55.681Z true test-image.png test-image-2.png false 123 tag-123 456 tag-456 2024-07-31T16:07:55.681Z variant_01J44RRKHRQ7WJ902X4936GEK7 Test variant true false 2024-07-31T16:07:55.704Z size large color green 30 45 100 prod_01J44RRKH4HH2SANJ0S05YM853 2024-07-31T16:07:55.704Z 0 import-shipping-profile
3 prod_01J44RRKH4HH2SANJ0S05YM853 base-product Base product draft test-product-description test line 2 test-image.png pcol_01J44RRKG4P1RZ3CKAMBS760TD ptyp_01J44RRKGHPQMJ1CRMW7MEN3P1 pcat_01J44RRKGS2N86A8V37ZJD877K 2024-07-31T16:07:55.681Z true test-image.png test-image-2.png false 123 tag-123 456 tag-456 2024-07-31T16:07:55.681Z variant_01J44RRKHRAYY7Q7NTXNNMDA1S Test variant 2 true false 2024-07-31T16:07:55.704Z size small color green 50 65 200 prod_01J44RRKH4HH2SANJ0S05YM853 2024-07-31T16:07:55.704Z 0 import-shipping-profile
4
5

View File

@@ -1,6 +1,6 @@
Product Id,Product Handle,Product Title,Product Status,Product Description,Product Subtitle,Product External Id,Product Thumbnail,Product Collection Id,Product Type Id,Product Created At,Product Deleted At,Product Discountable,Product Height,Product Hs Code,Product Image 1,Product Image 2,Product Is Giftcard,Product Length,Product Material,Product Mid Code,Product Origin Country,Product Tag 1,Product Tag 2,Product Updated At,Product Weight,Product Width,Variant Id,Variant Title,Variant Sku,Variant Upc,Variant Ean,Variant Hs Code,Variant Mid Code,Variant Manage Inventory,Variant Allow Backorder,Variant Barcode,Variant Created At,Variant Deleted At,Variant Height,Variant Length,Variant Material,Variant Metadata,Variant Option 1 Name,Variant Option 1 Value,Variant Option 2 Name,Variant Option 2 Value,Variant Origin Country,Variant Price DKK,Variant Price EUR,Variant Price USD,Variant Product Id,Variant Updated At,Variant Variant Rank,Variant Weight,Variant Width,Shipping Profile Id
prod_01J44RRJZ3M5F63NY82434RNM5,base-product,Base product,draft,"test-product-description
test line 2",,,test-image.png,pcol_01J44RRJXM6AM3YS5PMJDMH3YF,ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD,2024-07-31T16:07:55.102Z,,true,,,test-image.png,test-image-2.png,false,,,,,123,456,2024-07-31T16:07:55.102Z,,,variant_01J44RRJZW1T9KQB6XG7Q6K61F,Test variant,,,,,,true,false,,2024-07-31T16:07:55.133Z,,,,,,size,large,color,green,,30,45,100,prod_01J44RRJZ3M5F63NY82434RNM5,2024-07-31T16:07:55.133Z,0,,,import-shipping-profile
test line 2",,,test-image.png,pcol_01J44RRJXM6AM3YS5PMJDMH3YF,ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD,2024-07-31T16:07:55.102Z,,true,,,test-image.png,test-image-2.png,false,,,,,tag-123,tag-456,2024-07-31T16:07:55.102Z,,,variant_01J44RRJZW1T9KQB6XG7Q6K61F,Test variant,,,,,,true,false,,2024-07-31T16:07:55.133Z,,,,,,size,large,color,green,,30,45,100,prod_01J44RRJZ3M5F63NY82434RNM5,2024-07-31T16:07:55.133Z,0,,,import-shipping-profile
prod_01J44RRJZ3M5F63NY82434RNM5,base-product,Base product,draft,"test-product-description
test line 2",,,test-image.png,pcol_01J44RRJXM6AM3YS5PMJDMH3YF,ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD,2024-07-31T16:07:55.102Z,,true,,,test-image.png,test-image-2.png,false,,,,,123,456,2024-07-31T16:07:55.102Z,,,variant_01J44RRJZW5GNQKT1FEDACEESW,Test variant 2,,,,,,true,false,,2024-07-31T16:07:55.133Z,,,,,,size,small,color,green,,50,65,200,prod_01J44RRJZ3M5F63NY82434RNM5,2024-07-31T16:07:55.133Z,0,,,import-shipping-profile
prod_01J44RRK2GJJVMQQXT67TJCV08,proposed-product,Proposed product,proposed,test-product-description,,,test-image.png,,ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD,2024-07-31T16:07:55.213Z,,true,,,test-image.png,test-image-2.png,false,,,,,new-tag,,2024-07-31T16:07:55.213Z,,,variant_01J44RRK2WYHH0RDEK8BBGP7CY,Test variant,,,,,,true,false,,2024-07-31T16:07:55.228Z,,,,,,size,large,color,green,,30,45,100,prod_01J44RRK2GJJVMQQXT67TJCV08,2024-07-31T16:07:55.228Z,0,,,import-shipping-profile
test line 2",,,test-image.png,pcol_01J44RRJXM6AM3YS5PMJDMH3YF,ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD,2024-07-31T16:07:55.102Z,,true,,,test-image.png,test-image-2.png,false,,,,,tag-123,tag-456,2024-07-31T16:07:55.102Z,,,variant_01J44RRJZW5GNQKT1FEDACEESW,Test variant 2,,,,,,true,false,,2024-07-31T16:07:55.133Z,,,,,,size,small,color,green,,50,65,200,prod_01J44RRJZ3M5F63NY82434RNM5,2024-07-31T16:07:55.133Z,0,,,import-shipping-profile
,proposed-product,Proposed product,proposed,test-product-description,,,test-image.png,,ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD,2024-07-31T16:07:55.213Z,,true,,,test-image.png,test-image-2.png,false,,,,,new-tag,,2024-07-31T16:07:55.213Z,,,,Test variant,,,,,,true,false,,2024-07-31T16:07:55.228Z,,,,,,size,large,color,green,,30,45,100,,2024-07-31T16:07:55.228Z,0,,,import-shipping-profile
1 Product Id Product Handle Product Title Product Status Product Description Product Subtitle Product External Id Product Thumbnail Product Collection Id Product Type Id Product Created At Product Deleted At Product Discountable Product Height Product Hs Code Product Image 1 Product Image 2 Product Is Giftcard Product Length Product Material Product Mid Code Product Origin Country Product Tag 1 Product Tag 2 Product Updated At Product Weight Product Width Variant Id Variant Title Variant Sku Variant Upc Variant Ean Variant Hs Code Variant Mid Code Variant Manage Inventory Variant Allow Backorder Variant Barcode Variant Created At Variant Deleted At Variant Height Variant Length Variant Material Variant Metadata Variant Option 1 Name Variant Option 1 Value Variant Option 2 Name Variant Option 2 Value Variant Origin Country Variant Price DKK Variant Price EUR Variant Price USD Variant Product Id Variant Updated At Variant Variant Rank Variant Weight Variant Width Shipping Profile Id
2 prod_01J44RRJZ3M5F63NY82434RNM5 base-product Base product draft test-product-description test line 2 test-image.png pcol_01J44RRJXM6AM3YS5PMJDMH3YF ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD 2024-07-31T16:07:55.102Z true test-image.png test-image-2.png false 123 tag-123 456 tag-456 2024-07-31T16:07:55.102Z variant_01J44RRJZW1T9KQB6XG7Q6K61F Test variant true false 2024-07-31T16:07:55.133Z size large color green 30 45 100 prod_01J44RRJZ3M5F63NY82434RNM5 2024-07-31T16:07:55.133Z 0 import-shipping-profile
3 prod_01J44RRJZ3M5F63NY82434RNM5 base-product Base product draft test-product-description test line 2 test-image.png pcol_01J44RRJXM6AM3YS5PMJDMH3YF ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD 2024-07-31T16:07:55.102Z true test-image.png test-image-2.png false 123 tag-123 456 tag-456 2024-07-31T16:07:55.102Z variant_01J44RRJZW5GNQKT1FEDACEESW Test variant 2 true false 2024-07-31T16:07:55.133Z size small color green 50 65 200 prod_01J44RRJZ3M5F63NY82434RNM5 2024-07-31T16:07:55.133Z 0 import-shipping-profile
4 prod_01J44RRK2GJJVMQQXT67TJCV08 proposed-product Proposed product proposed test-product-description test-image.png ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD 2024-07-31T16:07:55.213Z true test-image.png test-image-2.png false new-tag 2024-07-31T16:07:55.213Z variant_01J44RRK2WYHH0RDEK8BBGP7CY Test variant true false 2024-07-31T16:07:55.228Z size large color green 30 45 100 prod_01J44RRK2GJJVMQQXT67TJCV08 2024-07-31T16:07:55.228Z 0 import-shipping-profile
5
6

View File

@@ -1,6 +1,6 @@
Product Id;Product Handle;Product Title;Product Status;Product Description;Product Subtitle;Product External Id;Product Thumbnail;Product Collection Id;Product Type Id;Product Created At;Product Deleted At;Product Discountable;Product Height;Product Hs Code;Product Image 1;Product Image 2;Product Is Giftcard;Product Length;Product Material;Product Mid Code;Product Origin Country;Product Tag 1;Product Tag 2;Product Updated At;Product Weight;Product Width;Variant Id;Variant Title;Variant Sku;Variant Upc;Variant Ean;Variant Hs Code;Variant Mid Code;Variant Manage Inventory;Variant Allow Backorder;Variant Barcode;Variant Created At;Variant Deleted At;Variant Height;Variant Length;Variant Material;Variant Metadata;Variant Option 1 Name;Variant Option 1 Value;Variant Option 2 Name;Variant Option 2 Value;Variant Origin Country;Variant Price DKK;Variant Price EUR;Variant Price USD;Variant Product Id;Variant Updated At;Variant Variant Rank;Variant Weight;Variant Width;Shipping Profile Id
prod_01J44RRJZ3M5F63NY82434RNM5;base-product;Base product;draft;"test-product-description
test line 2";;;test-image.png;pcol_01J44RRJXM6AM3YS5PMJDMH3YF;ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD;2024-07-31T16:07:55.102Z;;true;;;test-image.png;test-image-2.png;false;;;;;123;456;2024-07-31T16:07:55.102Z;;;variant_01J44RRJZW1T9KQB6XG7Q6K61F;Test variant;;;;;;true;false;;2024-07-31T16:07:55.133Z;;;;;;size;large;color;green;;30;45;100;prod_01J44RRJZ3M5F63NY82434RNM5;2024-07-31T16:07:55.133Z;0;;;import-shipping-profile
test line 2";;;test-image.png;pcol_01J44RRJXM6AM3YS5PMJDMH3YF;ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD;2024-07-31T16:07:55.102Z;;true;;;test-image.png;test-image-2.png;false;;;;;tag-123;tag-456;2024-07-31T16:07:55.102Z;;;variant_01J44RRJZW1T9KQB6XG7Q6K61F;Test variant;;;;;;true;false;;2024-07-31T16:07:55.133Z;;;;;;size;large;color;green;;30;45;100;prod_01J44RRJZ3M5F63NY82434RNM5;2024-07-31T16:07:55.133Z;0;;;import-shipping-profile
prod_01J44RRJZ3M5F63NY82434RNM5;base-product;Base product;draft;"test-product-description
test line 2";;;test-image.png;pcol_01J44RRJXM6AM3YS5PMJDMH3YF;ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD;2024-07-31T16:07:55.102Z;;true;;;test-image.png;test-image-2.png;false;;;;;123;456;2024-07-31T16:07:55.102Z;;;variant_01J44RRJZW5GNQKT1FEDACEESW;Test variant 2;;;;;;true;false;;2024-07-31T16:07:55.133Z;;;;;;size;small;color;green;;50;65;200;prod_01J44RRJZ3M5F63NY82434RNM5;2024-07-31T16:07:55.133Z;0;;;import-shipping-profile
prod_01J44RRK2GJJVMQQXT67TJCV08;proposed-product;Proposed product;proposed;test-product-description;;;test-image.png;;ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD;2024-07-31T16:07:55.213Z;;true;;;test-image.png;test-image-2.png;false;;;;;new-tag;;2024-07-31T16:07:55.213Z;;;variant_01J44RRK2WYHH0RDEK8BBGP7CY;Test variant;;;;;;true;false;;2024-07-31T16:07:55.228Z;;;;;;size;large;color;green;;30;45;100;prod_01J44RRK2GJJVMQQXT67TJCV08;2024-07-31T16:07:55.228Z;0;;;import-shipping-profile
test line 2";;;test-image.png;pcol_01J44RRJXM6AM3YS5PMJDMH3YF;ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD;2024-07-31T16:07:55.102Z;;true;;;test-image.png;test-image-2.png;false;;;;;tag-123;tag-456;2024-07-31T16:07:55.102Z;;;variant_01J44RRJZW5GNQKT1FEDACEESW;Test variant 2;;;;;;true;false;;2024-07-31T16:07:55.133Z;;;;;;size;small;color;green;;50;65;200;prod_01J44RRJZ3M5F63NY82434RNM5;2024-07-31T16:07:55.133Z;0;;;import-shipping-profile
;proposed-product;Proposed product;proposed;test-product-description;;;test-image.png;;ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD;2024-07-31T16:07:55.213Z;;true;;;test-image.png;test-image-2.png;false;;;;;new-tag;;2024-07-31T16:07:55.213Z;;;;Test variant;;;;;;true;false;;2024-07-31T16:07:55.228Z;;;;;;size;large;color;green;;30;45;100;;2024-07-31T16:07:55.228Z;0;;;import-shipping-profile
1 Product Id Product Handle Product Title Product Status Product Description Product Subtitle Product External Id Product Thumbnail Product Collection Id Product Type Id Product Created At Product Deleted At Product Discountable Product Height Product Hs Code Product Image 1 Product Image 2 Product Is Giftcard Product Length Product Material Product Mid Code Product Origin Country Product Tag 1 Product Tag 2 Product Updated At Product Weight Product Width Variant Id Variant Title Variant Sku Variant Upc Variant Ean Variant Hs Code Variant Mid Code Variant Manage Inventory Variant Allow Backorder Variant Barcode Variant Created At Variant Deleted At Variant Height Variant Length Variant Material Variant Metadata Variant Option 1 Name Variant Option 1 Value Variant Option 2 Name Variant Option 2 Value Variant Origin Country Variant Price DKK Variant Price EUR Variant Price USD Variant Product Id Variant Updated At Variant Variant Rank Variant Weight Variant Width Shipping Profile Id
2 prod_01J44RRJZ3M5F63NY82434RNM5 base-product Base product draft test-product-description test line 2 test-image.png pcol_01J44RRJXM6AM3YS5PMJDMH3YF ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD 2024-07-31T16:07:55.102Z true test-image.png test-image-2.png false 123 tag-123 456 tag-456 2024-07-31T16:07:55.102Z variant_01J44RRJZW1T9KQB6XG7Q6K61F Test variant true false 2024-07-31T16:07:55.133Z size large color green 30 45 100 prod_01J44RRJZ3M5F63NY82434RNM5 2024-07-31T16:07:55.133Z 0 import-shipping-profile
3 prod_01J44RRJZ3M5F63NY82434RNM5 base-product Base product draft test-product-description test line 2 test-image.png pcol_01J44RRJXM6AM3YS5PMJDMH3YF ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD 2024-07-31T16:07:55.102Z true test-image.png test-image-2.png false 123 tag-123 456 tag-456 2024-07-31T16:07:55.102Z variant_01J44RRJZW5GNQKT1FEDACEESW Test variant 2 true false 2024-07-31T16:07:55.133Z size small color green 50 65 200 prod_01J44RRJZ3M5F63NY82434RNM5 2024-07-31T16:07:55.133Z 0 import-shipping-profile
4 prod_01J44RRK2GJJVMQQXT67TJCV08 proposed-product Proposed product proposed test-product-description test-image.png ptyp_01J44RRJYAFEBZ2EY1KE1JM3XD 2024-07-31T16:07:55.213Z true test-image.png test-image-2.png false new-tag 2024-07-31T16:07:55.213Z variant_01J44RRK2WYHH0RDEK8BBGP7CY Test variant true false 2024-07-31T16:07:55.228Z size large color green 30 45 100 prod_01J44RRK2GJJVMQQXT67TJCV08 2024-07-31T16:07:55.228Z 0 import-shipping-profile
5
6

View File

@@ -0,0 +1,431 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` POST /admin/products/export should export a csv file containing the expected products 1`] = `
[
{
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description
test line 2",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "base-product",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "draft",
"Product Subtitle": "",
"Product Tag 1": "tag-123",
"Product Tag 2": "tag-456",
"Product Thumbnail": "test-image.png",
"Product Title": "Base product",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price DKK": 30,
"Variant Price EUR": 45,
"Variant Price USD": 100,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
{
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description
test line 2",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "base-product",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "draft",
"Product Subtitle": "",
"Product Tag 1": "tag-123",
"Product Tag 2": "tag-456",
"Product Thumbnail": "test-image.png",
"Product Title": "Base product",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "small",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price DKK": 50,
"Variant Price EUR": 65,
"Variant Price USD": 200,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant 2",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
{
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "proposed-product",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "proposed",
"Product Subtitle": "",
"Product Tag 1": "new-tag",
"Product Tag 2": "",
"Product Thumbnail": "test-image.png",
"Product Title": "Proposed product",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price DKK": 30,
"Variant Price EUR": 45,
"Variant Price USD": 100,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
]
`;
exports[` POST /admin/products/export should export a csv file filtered by specific products 1`] = `
[
{
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "proposed-product",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "proposed",
"Product Subtitle": "",
"Product Tag 1": "new-tag",
"Product Thumbnail": "test-image.png",
"Product Title": "Proposed product",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price DKK": 30,
"Variant Price EUR": 45,
"Variant Price USD": 100,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
]
`;
exports[` POST /admin/products/export should export a csv file with categories 1`] = `
[
{
"Product Category 1": "<ID>",
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description
test line 2",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "base-product",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "draft",
"Product Subtitle": "",
"Product Tag 1": "tag-123",
"Product Tag 2": "tag-456",
"Product Thumbnail": "test-image.png",
"Product Title": "Base product",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price DKK": 30,
"Variant Price EUR": 45,
"Variant Price USD": 100,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
{
"Product Category 1": "<ID>",
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description
test line 2",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "base-product",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "draft",
"Product Subtitle": "",
"Product Tag 1": "tag-123",
"Product Tag 2": "tag-456",
"Product Thumbnail": "test-image.png",
"Product Title": "Base product",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "small",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price DKK": 50,
"Variant Price EUR": 65,
"Variant Price USD": 200,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant 2",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
]
`;
exports[` POST /admin/products/export should export a csv file with region prices 1`] = `
[
{
"Product Collection Id": "<ID>",
"Product Created At": "<DateTime>",
"Product Deleted At": "<DateTime>",
"Product Description": "test-product-description",
"Product Discountable": true,
"Product External Id": "<ID>",
"Product Handle": "product-with-prices",
"Product Height": "",
"Product Hs Code": "",
"Product Id": "<ID>",
"Product Image 1": "test-image.png",
"Product Image 2": "test-image-2.png",
"Product Is Giftcard": false,
"Product Length": "",
"Product Material": "",
"Product Mid Code": "",
"Product Origin Country": "",
"Product Status": "draft",
"Product Subtitle": "",
"Product Tag 1": "tag-123",
"Product Tag 2": "tag-456",
"Product Thumbnail": "test-image.png",
"Product Title": "Product with prices",
"Product Type Id": "<ID>",
"Product Updated At": "<DateTime>",
"Product Weight": "",
"Product Width": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": "<DateTime>",
"Variant Deleted At": "<DateTime>",
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Id": "<ID>",
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
"Variant Option 2 Value": "green",
"Variant Origin Country": "",
"Variant Price Test Region [USD]": 45,
"Variant Price USD": 100,
"Variant Product Id": "<ID>",
"Variant Sku": "",
"Variant Title": "Test variant",
"Variant Upc": "",
"Variant Updated At": "<DateTime>",
"Variant Variant Rank": 0,
"Variant Weight": "",
"Variant Width": "",
},
]
`;

View File

@@ -11,38 +11,103 @@ import {
createAdminUser,
} from "../../../../helpers/create-admin-user"
import { getProductFixture } from "../../../../helpers/fixtures"
import { csv2json } from "json-2-csv"
jest.setTimeout(50000)
const compareCSVs = async (filePath, expectedFilePath) => {
const EXPORTED_COLUMNS = [
"Product Collection Id",
"Product Created At",
"Product Deleted At",
"Product Description",
"Product Discountable",
"Product External Id",
"Product Handle",
"Product Height",
"Product Hs Code",
"Product Id",
"Product Image *",
"Product Is Giftcard",
"Product Length",
"Product Material",
"Product Mid Code",
"Product Origin Country",
"Product Status",
"Product Subtitle",
"Product Tag *",
"Product Thumbnail",
"Product Title",
"Product Type Id",
"Product Updated At",
"Product Weight",
"Product Width",
"Variant Allow Backorder",
"Variant Barcode",
"Variant Created At",
"Variant Deleted At",
"Variant Ean",
"Variant Height",
"Variant Hs Code",
"Variant Id",
"Variant Length",
"Variant Manage Inventory",
"Variant Material",
"Variant Metadata",
"Variant Mid Code",
"Variant Option * Name",
"Variant Option * Value",
"Variant Origin Country",
"Variant Price [ISO]",
"Variant Product Id",
"Variant Sku",
"Variant Title",
"Variant Upc",
"Variant Updated At",
"Variant Variant Rank",
"Variant Weight",
"Variant Width",
]
const getCSVContents = async (filePath: string) => {
const asLocalPath = filePath.replace("http://localhost:9000", process.cwd())
let fileContent = await fs.readFile(asLocalPath, { encoding: "utf-8" })
let fixturesContent = await fs.readFile(expectedFilePath, {
encoding: "utf-8",
})
const fileContent = await fs.readFile(asLocalPath, { encoding: "utf-8" })
await fs.rm(path.dirname(asLocalPath), { recursive: true, force: true })
const csvRows = csv2json(fileContent)
// Normalize csv data to get rid of dynamic data
const idsToReplace = ["prod_", "pcol_", "variant_", "ptyp_", "pcat_"]
const dateRegex =
/(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})\.(\d{3})Z/g
idsToReplace.forEach((prefix) => {
fileContent = fileContent.replace(
new RegExp(`${prefix}\\w*\\d*`, "g"),
"<ID>"
)
fixturesContent = fixturesContent.replace(
new RegExp(`${prefix}\\w*\\d*`, "g"),
"<ID>"
)
return csvRows.reduce<any[]>((result, row) => {
const rowCopy = { ...row }
Object.keys(rowCopy).forEach((col) => {
if (
col.includes("Updated At") ||
col.includes("Created At") ||
col.includes("Deleted At")
) {
rowCopy[col] = "<DateTime>"
}
if (col.includes("Id") || col.startsWith("Product Category ")) {
rowCopy[col] = "<ID>"
}
})
result.push(rowCopy)
return result
}, [])
}
const assertExportedColumns = (rows: any[]) => {
rows.forEach((row) => {
EXPORTED_COLUMNS.forEach((column) => {
if (column.includes("[ISO]")) {
expect(
Object.keys(row).filter((rowCol) =>
rowCol.startsWith("Variant Price ")
).length
).toBeGreaterThanOrEqual(1)
} else {
expect(row).toHaveProperty(column.replace("*", "1"))
}
})
})
fileContent = fileContent.replace(dateRegex, "<DATE>")
fixturesContent = fixturesContent.replace(dateRegex, "<DATE>")
fixturesContent = fixturesContent.replace(/,Shipping Profile Id*/g, "")
fixturesContent = fixturesContent.replace(/,import-shipping-profile*/g, "")
expect(fileContent).toEqual(fixturesContent)
}
medusaIntegrationTestRunner({
@@ -121,11 +186,19 @@ medusaIntegrationTestRunner({
).data.product_category
baseTag1 = (
await api.post("/admin/product-tags", { value: "123" }, adminHeaders)
await api.post(
"/admin/product-tags",
{ value: "tag-123" },
adminHeaders
)
).data.product_tag
baseTag2 = (
await api.post("/admin/product-tags", { value: "456" }, adminHeaders)
await api.post(
"/admin/product-tags",
{ value: "tag-456" },
adminHeaders
)
).data.product_tag
newTag = (
@@ -252,10 +325,12 @@ medusaIntegrationTestRunner({
})
)
await compareCSVs(
notifications[0].data.file.url,
path.join(__dirname, "__fixtures__", "exported-products-comma.csv")
const exportedFileContents = await getCSVContents(
notifications[0].data.file.url
)
assertExportedColumns(exportedFileContents)
expect(exportedFileContents).toMatchSnapshot()
})
it("should export a csv file with categories", async () => {
@@ -278,10 +353,12 @@ medusaIntegrationTestRunner({
await api.get("/admin/notifications", adminHeaders)
).data.notifications
await compareCSVs(
notifications[0].data.file.url,
path.join(__dirname, "__fixtures__", "product-with-categories.csv")
const exportedFileContents = await getCSVContents(
notifications[0].data.file.url
)
assertExportedColumns(exportedFileContents)
expect(exportedFileContents).toMatchSnapshot()
})
it("should export a csv file with region prices", async () => {
@@ -338,10 +415,12 @@ medusaIntegrationTestRunner({
await api.get("/admin/notifications", adminHeaders)
).data.notifications
await compareCSVs(
notifications[0].data.file.url,
path.join(__dirname, "__fixtures__", "prices-with-region.csv")
const exportedFileContents = await getCSVContents(
notifications[0].data.file.url
)
assertExportedColumns(exportedFileContents)
expect(exportedFileContents).toMatchSnapshot()
})
it("should export a csv file filtered by specific products", async () => {
@@ -367,10 +446,12 @@ medusaIntegrationTestRunner({
expect(notifications.length).toBe(1)
await compareCSVs(
notifications[0].data.file.url,
path.join(__dirname, "__fixtures__", "filtered-products.csv")
const exportedFileContents = await getCSVContents(
notifications[0].data.file.url
)
assertExportedColumns(exportedFileContents)
expect(exportedFileContents).toMatchSnapshot()
})
})
},

View File

@@ -1,3 +1,4 @@
import { csv2json, json2csv } from "json-2-csv"
import {
medusaIntegrationTestRunner,
TestEventUtils,
@@ -13,6 +14,17 @@ import {
} from "../../../../helpers/create-admin-user"
import { getProductFixture } from "../../../../helpers/fixtures"
const UNALLOWED_EXPORTED_COLUMNS = [
"Product Is Giftcard",
"Product Created At",
"Product Updated At",
"Product Deleted At",
"Variant Product Id",
"Variant Created At",
"Variant Updated At",
"Variant Deleted At",
]
jest.setTimeout(50000)
const getUploadReq = (file: { name: string; content: string }) => {
@@ -29,7 +41,21 @@ const getUploadReq = (file: { name: string; content: string }) => {
}
}
function prepareCSVForImport(fileContents: string, delimiter: string = ",") {
const CSVFileAsJSON = csv2json(fileContents, {
delimiter: { field: delimiter },
})
CSVFileAsJSON.forEach((row) => {
UNALLOWED_EXPORTED_COLUMNS.forEach((col) => {
delete row[col]
})
})
return json2csv(CSVFileAsJSON)
}
medusaIntegrationTestRunner({
dbName: "bulk-uploads-local",
testSuite: ({ dbConnection, getContainer, api }) => {
let baseCollection
let baseType
@@ -66,15 +92,27 @@ medusaIntegrationTestRunner({
).data.product_type
baseTag1 = (
await api.post("/admin/product-tags", { value: "123" }, adminHeaders)
await api.post(
"/admin/product-tags",
{ value: "tag-123" },
adminHeaders
)
).data.product_tag
baseTag2 = (
await api.post("/admin/product-tags", { value: "123_1" }, adminHeaders)
await api.post(
"/admin/product-tags",
{ value: "tag-123_1" },
adminHeaders
)
).data.product_tag
baseTag3 = (
await api.post("/admin/product-tags", { value: "456" }, adminHeaders)
await api.post(
"/admin/product-tags",
{ value: "tag-456" },
adminHeaders
)
).data.product_tag
newTag = (
@@ -129,13 +167,18 @@ medusaIntegrationTestRunner({
;(eventBus as any).eventEmitter_.removeAllListeners()
})
describe("POST /admin/products/export", () => {
describe("POST /admin/products/import", () => {
// We want to ensure files with different delimiters are supported
;[
{ file: "exported-products-comma.csv", name: "delimited with comma" },
{
file: "exported-products-semicolon.csv",
file: "products-comma.csv",
name: "delimited with comma",
delimiter: ",",
},
{
file: "products-semicolon.csv",
name: "delimited with semicolon",
delimiter: ";",
},
].forEach((testcase) => {
it(`should import a previously exported products CSV file ${testcase.name}`, async () => {
@@ -157,8 +200,12 @@ medusaIntegrationTestRunner({
/variant_01J44RRJZW1T9KQB6XG7Q6K61F/g,
baseProduct.variants[0].id
)
fileContent = fileContent.replace(/pcol_\w*\d*/g, baseCollection.id)
fileContent = fileContent.replace(/ptyp_\w*\d*/g, baseType.id)
fileContent = fileContent.replace(/tag-123/g, baseTag1.id)
fileContent = fileContent.replace(/tag-456/g, baseTag3.id)
fileContent = fileContent.replace(/new-tag/g, newTag.id)
fileContent = fileContent.replace(
/import-shipping-profile*/g,
@@ -167,7 +214,7 @@ medusaIntegrationTestRunner({
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
content: prepareCSVForImport(fileContent, testcase.delimiter),
})
// BREAKING: The batch endpoints moved to the domain routes (admin/batch-jobs -> /admin/products/import). The payload and response changed as well.
@@ -209,192 +256,193 @@ medusaIntegrationTestRunner({
.data.products
expect(dbProducts).toHaveLength(2)
expect(dbProducts).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: baseProduct.id,
handle: "base-product",
is_giftcard: false,
thumbnail: "test-image.png",
status: "draft",
description: "test-product-description\ntest line 2",
options: expect.arrayContaining([
expect.objectContaining({
title: "size",
values: expect.arrayContaining([
expect.objectContaining({
value: "small",
}),
expect.objectContaining({
value: "large",
}),
]),
}),
expect.objectContaining({
title: "color",
values: expect.arrayContaining([
expect.objectContaining({
value: "green",
}),
]),
}),
]),
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
expect.objectContaining({
url: "test-image-2.png",
}),
]),
tags: [
expect.objectContaining({
value: "123",
}),
expect.objectContaining({
value: "456",
}),
],
type: expect.objectContaining({
id: baseType.id,
expect(dbProducts[0]).toEqual(
expect.objectContaining({
id: baseProduct.id,
handle: "base-product",
is_giftcard: false,
thumbnail: "test-image.png",
status: "draft",
description: "test-product-description\ntest line 2",
options: expect.arrayContaining([
expect.objectContaining({
title: "size",
values: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "small",
}),
]),
}),
collection: expect.objectContaining({
id: baseCollection.id,
expect.objectContaining({
title: "color",
values: expect.arrayContaining([
expect.objectContaining({
value: "green",
}),
]),
}),
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "dkk",
amount: 30,
}),
expect.objectContaining({
currency_code: "eur",
amount: 45,
}),
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "green",
}),
]),
}),
expect.objectContaining({
title: "Test variant 2",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "dkk",
amount: 50,
}),
expect.objectContaining({
currency_code: "eur",
amount: 65,
}),
expect.objectContaining({
currency_code: "usd",
amount: 200,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "small",
}),
expect.objectContaining({
value: "green",
}),
]),
}),
]),
created_at: expect.any(String),
updated_at: expect.any(String),
]),
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
expect.objectContaining({
url: "test-image-2.png",
}),
]),
tags: [
expect.objectContaining({
id: baseTag1.id,
}),
expect.objectContaining({
id: baseTag3.id,
}),
],
type: expect.objectContaining({
id: baseType.id,
}),
expect.objectContaining({
id: expect.any(String),
handle: "proposed-product",
is_giftcard: false,
thumbnail: "test-image.png",
status: "proposed",
description: "test-product-description",
options: expect.arrayContaining([
expect.objectContaining({
title: "size",
values: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
]),
}),
expect.objectContaining({
title: "color",
values: expect.arrayContaining([
expect.objectContaining({
value: "green",
}),
]),
}),
]),
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
expect.objectContaining({
url: "test-image-2.png",
}),
]),
tags: [
expect.objectContaining({
value: "new-tag",
}),
],
type: expect.objectContaining({
id: baseType.id,
}),
collection: null,
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "dkk",
amount: 30,
}),
expect.objectContaining({
currency_code: "eur",
amount: 45,
}),
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "green",
}),
]),
}),
]),
created_at: expect.any(String),
updated_at: expect.any(String),
collection: expect.objectContaining({
id: baseCollection.id,
}),
])
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "dkk",
amount: 30,
}),
expect.objectContaining({
currency_code: "eur",
amount: 45,
}),
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "green",
}),
]),
}),
expect.objectContaining({
title: "Test variant 2",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "dkk",
amount: 50,
}),
expect.objectContaining({
currency_code: "eur",
amount: 65,
}),
expect.objectContaining({
currency_code: "usd",
amount: 200,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "small",
}),
expect.objectContaining({
value: "green",
}),
]),
}),
]),
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
expect(dbProducts[1]).toEqual(
expect.objectContaining({
id: expect.any(String),
handle: "proposed-product",
is_giftcard: false,
thumbnail: "test-image.png",
status: "proposed",
description: "test-product-description",
options: expect.arrayContaining([
expect.objectContaining({
title: "size",
values: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
]),
}),
expect.objectContaining({
title: "color",
values: expect.arrayContaining([
expect.objectContaining({
value: "green",
}),
]),
}),
]),
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
expect.objectContaining({
url: "test-image-2.png",
}),
]),
tags: [
expect.objectContaining({
value: "new-tag",
}),
],
type: expect.objectContaining({
id: baseType.id,
}),
collection: null,
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "dkk",
amount: 30,
}),
expect.objectContaining({
currency_code: "eur",
amount: 45,
}),
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "green",
}),
]),
}),
]),
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
})
})
@@ -414,6 +462,9 @@ medusaIntegrationTestRunner({
fileContent = fileContent.replace(/pcol_\w*\d*/g, baseCollection.id)
fileContent = fileContent.replace(/ptyp_\w*\d*/g, baseType.id)
fileContent = fileContent.replace(/pcat_\w*\d*/g, baseCategory.id)
fileContent = fileContent.replace(/tag-123/g, baseTag1.id)
fileContent = fileContent.replace(/tag-456/g, baseTag3.id)
fileContent = fileContent.replace(/new-tag/g, newTag.id)
fileContent = fileContent.replace(
/import-shipping-profile*/g,
@@ -422,7 +473,7 @@ medusaIntegrationTestRunner({
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
content: prepareCSVForImport(fileContent),
})
const batchJobRes = await api.post("/admin/products/import", form, meta)
@@ -454,36 +505,7 @@ medusaIntegrationTestRunner({
)
})
it("should fail on invalid region in prices being present in the CSV", async () => {
let fileContent = await fs.readFile(
path.join(__dirname, "__fixtures__", "invalid-prices.csv"),
{ encoding: "utf-8" }
)
fileContent = fileContent.replace(
/import-shipping-profile*/g,
shippingProfile.id
)
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
})
const err = await api
.post("/admin/products/import", form, meta)
.catch((e) => e)
expect(err.response.data.message).toEqual(
"Region with name nonexistent not found"
)
})
it("should ignore non-existent fields being present in the CSV that don't start with Product or Variant", async () => {
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
`${Modules.NOTIFICATION}.notification.${CommonEvents.CREATED}`,
eventBus
)
it("should complain about non-existent fields being present in the CSV", async () => {
let fileContent = await fs.readFile(
path.join(__dirname, "__fixtures__", "unrelated-column.csv"),
{ encoding: "utf-8" }
@@ -491,6 +513,9 @@ medusaIntegrationTestRunner({
fileContent = fileContent.replace(/pcol_\w*\d*/g, baseCollection.id)
fileContent = fileContent.replace(/ptyp_\w*\d*/g, baseType.id)
fileContent = fileContent.replace(/tag-123/g, baseTag1.id)
fileContent = fileContent.replace(/tag-456/g, baseTag3.id)
fileContent = fileContent.replace(/new-tag/g, newTag.id)
fileContent = fileContent.replace(
/import-shipping-profile*/g,
@@ -499,46 +524,19 @@ medusaIntegrationTestRunner({
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
content: prepareCSVForImport(fileContent),
})
const batchJobRes = await api.post("/admin/products/import", form, meta)
const batchJobRes = await api
.post("/admin/products/import", form, meta)
.catch((e) => e)
const transactionId = batchJobRes.data.transaction_id
expect(transactionId).toBeTruthy()
expect(batchJobRes.data.summary).toEqual({
toCreate: 1,
toUpdate: 0,
})
await api.post(
`/admin/products/import/${transactionId}/confirm`,
{},
meta
)
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!`,
}),
})
expect(batchJobRes.response.data.message).toEqual(
'Invalid column name(s) "Some field"'
)
})
it("should successfully skip non-existent product fields being present in the CSV", async () => {
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
`${Modules.NOTIFICATION}.notification.${CommonEvents.CREATED}`,
eventBus
)
let fileContent = await fs.readFile(
path.join(__dirname, "__fixtures__", "invalid-column.csv"),
{ encoding: "utf-8" }
@@ -554,313 +552,15 @@ medusaIntegrationTestRunner({
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
content: prepareCSVForImport(fileContent),
})
const batchJobRes = await api.post("/admin/products/import", form, meta)
const transactionId = batchJobRes.data.transaction_id
expect(transactionId).toBeTruthy()
expect(batchJobRes.data.summary).toEqual({
toCreate: 1,
toUpdate: 0,
})
await api.post(
`/admin/products/import/${transactionId}/confirm`,
{},
meta
)
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!",
}),
})
)
const [importedProduct] = (
await api.get("/admin/products?limit=1&order=-id", adminHeaders)
).data.products
expect(importedProduct).not.toEqual(
expect.objectContaining({
field: "Test product",
})
)
})
it("supports importing the v1 template", async () => {
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
`${Modules.NOTIFICATION}.notification.${CommonEvents.CREATED}`,
eventBus
)
let fileContent = await fs.readFile(
path.join(__dirname, "__fixtures__", "v1-template.csv"),
{ encoding: "utf-8" }
)
fileContent = fileContent.replace(
/existing-product-id/g,
baseProduct.id
)
fileContent = fileContent.replace(
/existing-variant-id/g,
baseProduct.variants[0].id
)
fileContent = fileContent.replace(/test-type/g, baseType.value)
fileContent = fileContent.replace(
/test-collection1/g,
baseCollection.handle
)
fileContent = fileContent.replace(
/test-collection2/g,
baseCollection.handle
)
fileContent = fileContent.replace(
/import-shipping-profile*/g,
shippingProfile.id
)
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
})
const batchJobRes = await api.post("/admin/products/import", form, meta)
const transactionId = batchJobRes.data.transaction_id
expect(transactionId).toBeTruthy()
expect(batchJobRes.data.summary).toEqual({
toCreate: 2,
toUpdate: 1,
})
await api.post(
`/admin/products/import/${transactionId}/confirm`,
{},
meta
)
await subscriberExecution
const dbProducts = (await api.get("/admin/products", adminHeaders)).data
.products
expect(dbProducts).toHaveLength(3)
expect(dbProducts).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: baseProduct.id,
handle: "test-product-product-2",
title: "Test product",
is_giftcard: false,
thumbnail: "test-image.png",
status: "draft",
description: "test-product-description",
options: expect.arrayContaining([
expect.objectContaining({
title: "Size",
values: expect.arrayContaining([
expect.objectContaining({
value: "Small",
}),
expect.objectContaining({
value: "Medium",
}),
expect.objectContaining({
value: "Large",
}),
]),
}),
]),
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
]),
tags: [
expect.objectContaining({
value: "123",
}),
],
type: expect.objectContaining({
id: baseType.id,
}),
collection: expect.objectContaining({
id: baseCollection.id,
}),
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
sku: "test-sku-2",
barcode: "test-barcode-2",
allow_backorder: false,
manage_inventory: true,
prices: [
expect.objectContaining({
currency_code: "usd",
amount: 1.1,
}),
],
options: [
expect.objectContaining({
value: "Small",
}),
],
}),
expect.objectContaining({
title: "Test variant",
sku: "test-sku-3",
barcode: "test-barcode-3",
allow_backorder: false,
manage_inventory: true,
prices: [
expect.objectContaining({
currency_code: "usd",
amount: 1.2,
}),
],
options: [
expect.objectContaining({
value: "Medium",
}),
],
}),
expect.objectContaining({
id: baseProduct.variants[0].id,
title: "Test variant changed",
sku: "test-sku-4",
barcode: "test-barcode-4",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
expect.objectContaining({
currency_code: "eur",
amount: 45,
}),
expect.objectContaining({
currency_code: "dkk",
amount: 30,
}),
]),
options: [
expect.objectContaining({
value: "Large",
}),
],
}),
]),
created_at: expect.any(String),
updated_at: expect.any(String),
}),
expect.objectContaining({
id: expect.any(String),
title: "Test product",
handle: "test-product-product-1-1",
is_giftcard: false,
thumbnail: "test-image.png",
status: "draft",
description:
"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",
options: expect.arrayContaining([
expect.objectContaining({
title: "test-option-1",
values: expect.arrayContaining([
expect.objectContaining({
value: "option 1 value red",
}),
]),
}),
expect.objectContaining({
title: "test-option-2",
values: expect.arrayContaining([
expect.objectContaining({
value: "option 2 value 1",
}),
]),
}),
]),
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
]),
tags: [],
type: null,
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
sku: "test-sku-1-1",
barcode: "test-barcode-1-1",
allow_backorder: false,
manage_inventory: true,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "usd",
rules: {
region_id: baseRegion.id,
},
amount: 1,
}),
expect.objectContaining({
currency_code: "usd",
amount: 1.1,
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "option 1 value red",
}),
expect.objectContaining({
value: "option 2 value 1",
}),
]),
}),
]),
created_at: expect.any(String),
updated_at: expect.any(String),
}),
expect.objectContaining({
id: expect.any(String),
title: "Test product",
handle: "test-product-product-1",
}),
])
)
})
it("should fail when a v1 import has non existent collection", async () => {
let fileContent = await fs.readFile(
path.join(__dirname, "__fixtures__", "v1-template.csv"),
{ encoding: "utf-8" }
)
const { form, meta } = getUploadReq({
name: "test.csv",
content: fileContent,
})
const err = await api
const batchJobRes = await api
.post("/admin/products/import", form, meta)
.catch((e) => e)
expect(err.response.data.message).toEqual(
"Product collection with handle 'test-collection1' does not exist"
expect(batchJobRes.response.data.message).toEqual(
'Invalid column name(s) "Product field"'
)
})
})

View File

@@ -41,6 +41,7 @@
"@swc/core": "^1.4.8",
"@swc/jest": "^0.2.36",
"jest": "^29.7.0",
"json-2-csv": "^5.5.9",
"typescript": "^5.6.2"
}
}

View File

@@ -6,7 +6,7 @@ import {
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { normalizeForExport } from "../helpers/normalize-for-export"
import { convertJsonToCsv } from "../utlils"
import { convertJsonToCsv } from "../utils"
const prodColumnPositions = new Map([
["Product Id", 0],
@@ -81,13 +81,13 @@ export const generateProductCsvStepId = "generate-product-csv"
/**
* This step generates a CSV file that exports products. The CSV
* file is created and stored using the registered [File Module Provider](https://docs.medusajs.com/resources/infrastructure-modules/file).
*
*
* @example
* const { data: products } = useQueryGraphStep({
* entity: "product",
* fields: ["*", "variants.*", "collection.*", "categories.*"]
* })
*
*
* // @ts-ignore
* const data = generateProductCsvStep(products)
*/
@@ -118,7 +118,7 @@ export const generateProductCsvStep = createStep(
})
return new StepResponse(
{ id: file.id, filename } as GenerateProductCsvStepOutput,
{ id: file.id, filename } as GenerateProductCsvStepOutput,
file.id
)
},

View File

@@ -1,117 +0,0 @@
import { HttpTypes, IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* The products to group.
*/
export type GroupProductsForBatchStepInput = (HttpTypes.AdminCreateProduct & {
/**
* The ID of the product to update.
*/
id?: string
})[]
export type GroupProductsForBatchStepOutput = {
/**
* The products to create.
*/
create: HttpTypes.AdminCreateProduct[]
/**
* The products to update.
*/
update: (HttpTypes.AdminUpdateProduct & { id: string })[]
}
export const groupProductsForBatchStepId = "group-products-for-batch"
/**
* This step groups products to be created or updated.
*
* @example
* const data = groupProductsForBatchStep([
* {
* id: "prod_123",
* title: "Shirt",
* options: [
* {
* title: "Size",
* values: ["S", "M", "L"]
* }
* ]
* },
* {
* title: "Pants",
* options: [
* {
* title: "Color",
* values: ["Red", "Blue"]
* }
* ],
* variants: [
* {
* title: "Red Pants",
* options: {
* Color: "Red"
* },
* prices: [
* {
* amount: 10,
* currency_code: "usd"
* }
* ]
* }
* ]
* }
* ])
*/
export const groupProductsForBatchStep = createStep(
groupProductsForBatchStepId,
async (
data: GroupProductsForBatchStepInput,
{ container }
) => {
const service = container.resolve<IProductModuleService>(Modules.PRODUCT)
const existingProducts = await service.listProducts(
{
// We use the ID to do product updates
id: data.map((product) => product.id).filter(Boolean) as string[],
},
{ select: ["handle"] }
)
const existingProductsSet = new Set(existingProducts.map((p) => p.id))
const { toUpdate, toCreate } = data.reduce(
(
acc: {
toUpdate: (HttpTypes.AdminUpdateProduct & { id: string })[]
toCreate: HttpTypes.AdminCreateProduct[]
},
product
) => {
// There are few data normalizations to do if we are dealing with an update.
if (product.id && existingProductsSet.has(product.id)) {
acc.toUpdate.push(
product as HttpTypes.AdminUpdateProduct & { id: string }
)
return acc
}
// New products and variants will be created with a new ID, even if there is one present in the CSV.
// To add support for creating with predefined IDs we will need to do changes to the upsert method.
delete product.id
product.variants?.forEach((variant) => {
delete (variant as any).id
})
acc.toCreate.push(product)
return acc
},
{ toUpdate: [], toCreate: [] }
)
return new StepResponse(
{ create: toCreate, update: toUpdate } as GroupProductsForBatchStepOutput,
)
}
)

View File

@@ -23,7 +23,6 @@ export * from "./update-product-tags"
export * from "./delete-product-tags"
export * from "./generate-product-csv"
export * from "./parse-product-csv"
export * from "./group-products-for-batch"
export * from "./wait-confirmation-product-import"
export * from "./get-variant-availability"
export * from "./normalize-products-v1"
export * from "./normalize-products"

View File

@@ -1,7 +1,7 @@
import { CSVNormalizer } from "@medusajs/framework/utils"
import { HttpTypes } from "@medusajs/framework/types"
import { CSVNormalizer, productValidators } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { convertCsvToJson } from "../utlils"
import { GroupProductsForBatchStepOutput } from "./group-products-for-batch"
import { convertCsvToJson } from "../utils"
/**
* The CSV file content to parse.
@@ -18,7 +18,7 @@ export const normalizeCsvStepId = "normalize-product-csv"
*/
export const normalizeCsvStep = createStep(
normalizeCsvStepId,
async (fileContent: NormalizeProductCsvStepInput, { container }) => {
async (fileContent: NormalizeProductCsvStepInput) => {
const csvProducts =
convertCsvToJson<ConstructorParameters<typeof CSVNormalizer>[0][0]>(
fileContent
@@ -27,22 +27,28 @@ export const normalizeCsvStep = createStep(
const products = normalizer.proccess()
const create = Object.keys(products.toCreate).reduce<
(typeof products)["toCreate"][keyof (typeof products)["toCreate"]][]
HttpTypes.AdminCreateProduct[]
>((result, toCreateHandle) => {
result.push(products.toCreate[toCreateHandle])
result.push(
productValidators.CreateProduct.parse(
products.toCreate[toCreateHandle]
) as HttpTypes.AdminCreateProduct
)
return result
}, [])
const update = Object.keys(products.toUpdate).reduce<
(typeof products)["toUpdate"][keyof (typeof products)["toUpdate"]][]
>((result, toCreateId) => {
result.push(products.toUpdate[toCreateId])
HttpTypes.AdminUpdateProduct & { id: string }[]
>((result, toUpdateId) => {
result.push(
productValidators.UpdateProduct.parse(products.toUpdate[toUpdateId])
)
return result
}, [])
return new StepResponse({
create,
update,
} as GroupProductsForBatchStepOutput)
})
}
)

View File

@@ -8,7 +8,7 @@ import { MedusaError, Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { normalizeForImport } from "../helpers/normalize-for-import"
import { normalizeV1Products } from "../helpers/normalize-v1-import"
import { convertCsvToJson } from "../utlils"
import { convertCsvToJson } from "../utils"
/**
* The CSV file content to parse.

View File

@@ -6,11 +6,7 @@ import {
transform,
} from "@medusajs/framework/workflows-sdk"
import { notifyOnFailureStep, sendNotificationsStep } from "../../notification"
import {
groupProductsForBatchStep,
parseProductCsvStep,
waitConfirmationProductImportStep,
} from "../steps"
import { normalizeCsvStep, waitConfirmationProductImportStep } from "../steps"
import { batchProductsWorkflow } from "./batch-products"
export const importProductsWorkflowId = "import-products"
@@ -94,8 +90,7 @@ export const importProductsWorkflow = createWorkflow(
(
input: WorkflowData<WorkflowTypes.ProductWorkflow.ImportProductsDTO>
): WorkflowResponse<WorkflowTypes.ProductWorkflow.ImportProductsSummary> => {
const products = parseProductCsvStep(input.fileContent)
const batchRequest = groupProductsForBatchStep(products)
const batchRequest = normalizeCsvStep(input.fileContent)
const summary = transform({ batchRequest }, (data) => {
return {

View File

@@ -65,11 +65,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -78,7 +78,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {},
@@ -136,11 +136,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -157,11 +157,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -178,11 +178,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -191,7 +191,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {},
@@ -258,11 +258,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -280,11 +280,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -303,11 +303,11 @@ describe("CSV processor", () => {
"origin_country": "EU",
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -316,7 +316,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {},
@@ -377,11 +377,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -398,11 +398,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -419,11 +419,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -440,11 +440,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -453,7 +453,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"sweatpants": {
"categories": [],
@@ -498,11 +498,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -519,11 +519,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -540,11 +540,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -561,11 +561,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -574,7 +574,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"t-shirt": {
"categories": [],
@@ -600,12 +600,8 @@ describe("CSV processor", () => {
"title": "Size",
"values": [
"S",
"S",
"M",
"M",
"L",
"L",
"XL",
"XL",
],
},
@@ -614,12 +610,6 @@ describe("CSV processor", () => {
"values": [
"Black",
"White",
"Black",
"White",
"Black",
"White",
"Black",
"White",
],
},
],
@@ -643,11 +633,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -665,11 +655,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -687,11 +677,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -709,11 +699,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -731,11 +721,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -753,11 +743,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -775,11 +765,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -797,11 +787,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -810,7 +800,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {
@@ -858,11 +848,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -879,11 +869,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -900,11 +890,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -921,11 +911,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -934,7 +924,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"prod_01JT598HEWAE555V0A6BD602MG": {
"categories": [],
@@ -970,11 +960,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "1000",
"amount": 1000,
"currency_code": "eur",
},
{
"amount": "1200",
"amount": 1200,
"currency_code": "usd",
},
],
@@ -982,7 +972,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"prod_01JT598HEX26EHDG7SRK37Q3FG": {
"categories": [],
@@ -1024,11 +1014,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1044,11 +1034,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1064,11 +1054,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1084,11 +1074,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1096,7 +1086,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
}

View File

@@ -169,25 +169,17 @@ const productStaticColumns: {
"product discountable",
"discountable"
),
"product height": processAsNumber("product height", "height", {
asNumericString: true,
}),
"product height": processAsNumber("product height", "height"),
"product hs code": processAsString("product hs code", "hs_code"),
"product length": processAsNumber("product length", "length", {
asNumericString: true,
}),
"product length": processAsNumber("product length", "length"),
"product material": processAsString("product material", "material"),
"product mid code": processAsString("product mid code", "mid_code"),
"product origin country": processAsString(
"product origin country",
"origin_country"
),
"product weight": processAsNumber("product weight", "weight", {
asNumericString: true,
}),
"product width": processAsNumber("product width", "width", {
asNumericString: true,
}),
"product weight": processAsNumber("product weight", "weight"),
"product width": processAsNumber("product width", "width"),
"product metadata": processAsString("product metadata", "metadata"),
"shipping profile id": processAsString(
"shipping profile id",
@@ -243,12 +235,8 @@ const variantStaticColumns: {
"allow_backorder"
),
"variant barcode": processAsString("variant barcode", "barcode"),
"variant height": processAsNumber("variant height", "height", {
asNumericString: true,
}),
"variant length": processAsNumber("variant length", "length", {
asNumericString: true,
}),
"variant height": processAsNumber("variant height", "height"),
"variant length": processAsNumber("variant length", "length"),
"variant material": processAsString("variant material", "material"),
"variant metadata": processAsString("variant metadata", "metadata"),
"variant origin country": processAsString(
@@ -259,12 +247,8 @@ const variantStaticColumns: {
"variant variant rank",
"variant_rank"
),
"variant width": processAsNumber("variant width", "width", {
asNumericString: true,
}),
"variant weight": processAsNumber("variant weight", "weight", {
asNumericString: true,
}),
"variant width": processAsNumber("variant width", "width"),
"variant weight": processAsNumber("variant weight", "weight"),
}
/**
@@ -295,7 +279,7 @@ const variantWildcardColumns: {
} else {
output["prices"].push({
currency_code: iso,
amount: String(numericValue),
amount: numericValue,
})
}
})
@@ -438,7 +422,7 @@ export class CSVNormalizer {
}, {})
if (unknownColumns.length) {
return new MedusaError(
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid column name(s) "${unknownColumns.join('","')}"`
)
@@ -509,7 +493,7 @@ export class CSVNormalizer {
)
if (!matchingKey) {
product.options.push({ title: key, values: [value] })
} else {
} else if (!matchingKey.values.includes(value)) {
matchingKey.values.push(value)
}
})

View File

@@ -0,0 +1,6 @@
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}

View File

@@ -1,10 +1,5 @@
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
export * from "./events"
export * from "./get-variant-availability"
export * from "./enums"
export * from "./csv-normalizer"
export * from "./get-variant-availability"
export * as productValidators from "./validators"

View File

@@ -0,0 +1,173 @@
import { z } from "zod"
import { ProductStatus } from "./enums"
export const booleanString = () =>
z
.union([z.boolean(), z.string()])
.refine((value) => {
return ["true", "false"].includes(value.toString().toLowerCase())
})
.transform((value) => {
return value.toString().toLowerCase() === "true"
})
export const numericString = () =>
z.number().transform((value) => {
return value !== null && value !== undefined ? String(value) : value
})
export const IdAssociation = z.object({
id: z.string(),
})
const statusEnum = z.nativeEnum(ProductStatus)
export const CreateVariantPrice = z.object({
currency_code: z.string(),
amount: z.number(),
min_quantity: z.number().nullish(),
max_quantity: z.number().nullish(),
rules: z.record(z.string(), z.string()).optional(),
})
export const CreateProductOption = z.object({
title: z.string(),
values: z.array(z.string()),
})
export const CreateProductVariant = z
.object({
title: z.string(),
sku: z.string().nullish(),
ean: z.string().nullish(),
upc: z.string().nullish(),
barcode: z.string().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
allow_backorder: booleanString().optional().default(false),
manage_inventory: booleanString().optional().default(true),
variant_rank: z.number().optional(),
weight: z.number().nullish(),
length: z.number().nullish(),
height: z.number().nullish(),
width: z.number().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
prices: z.array(CreateVariantPrice),
options: z.record(z.string()).optional(),
inventory_items: z
.array(
z.object({
inventory_item_id: z.string(),
required_quantity: z.number(),
})
)
.optional(),
})
.strict()
export const CreateProduct = z
.object({
title: z.string(),
subtitle: z.string().nullish(),
description: z.string().nullish(),
is_giftcard: booleanString().optional().default(false),
discountable: booleanString().optional().default(true),
images: z.array(z.object({ url: z.string() })).optional(),
thumbnail: z.string().nullish(),
handle: z.string().optional(),
status: statusEnum.optional().default(ProductStatus.DRAFT),
external_id: z.string().nullish(),
type_id: z.string().nullish(),
collection_id: z.string().nullish(),
categories: z.array(IdAssociation).optional(),
tags: z.array(IdAssociation).optional(),
options: z.array(CreateProductOption).optional(),
variants: z.array(CreateProductVariant).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string().optional(),
weight: z.number().nullish(),
length: z.number().nullish(),
height: z.number().nullish(),
width: z.number().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
})
.strict()
export const UpdateProductOption = z.object({
id: z.string().optional(),
title: z.string().optional(),
values: z.array(z.string()).optional(),
})
export const UpdateVariantPrice = z.object({
id: z.string().optional(),
currency_code: z.string().optional(),
amount: z.number().optional(),
min_quantity: z.number().nullish(),
max_quantity: z.number().nullish(),
rules: z.record(z.string(), z.string()).optional(),
})
export const UpdateProductVariant = z
.object({
id: z.string().optional(),
title: z.string().optional(),
prices: z.array(UpdateVariantPrice).optional(),
sku: z.string().nullish(),
ean: z.string().nullish(),
upc: z.string().nullish(),
barcode: z.string().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
allow_backorder: booleanString().optional(),
manage_inventory: booleanString().optional(),
variant_rank: z.number().optional(),
weight: numericString().nullish(),
length: numericString().nullish(),
height: numericString().nullish(),
width: numericString().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
options: z.record(z.string()).optional(),
})
.strict()
export const UpdateProduct = z
.object({
id: z.string(),
title: z.string().optional(),
discountable: booleanString().optional(),
is_giftcard: booleanString().optional(),
options: z.array(UpdateProductOption).optional(),
variants: z.array(UpdateProductVariant).optional(),
status: statusEnum.optional(),
subtitle: z.string().nullish(),
description: z.string().nullish(),
images: z.array(z.object({ url: z.string() })).optional(),
thumbnail: z.string().nullish(),
handle: z.string().nullish(),
type_id: z.string().nullish(),
external_id: z.string().nullish(),
collection_id: z.string().nullish(),
categories: z.array(IdAssociation).optional(),
tags: z.array(IdAssociation).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string().nullish(),
weight: numericString().nullish(),
length: numericString().nullish(),
height: numericString().nullish(),
width: numericString().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
})
.strict()

View File

@@ -23530,6 +23530,7 @@ __metadata:
"@swc/jest": ^0.2.36
form-data: ^4.0.0
jest: ^29.7.0
json-2-csv: ^5.5.9
jsonwebtoken: ^9.0.2
pg: ^8.11.3
typescript: ^5.6.2
@@ -25062,6 +25063,16 @@ __metadata:
languageName: node
linkType: hard
"json-2-csv@npm:^5.5.9":
version: 5.5.9
resolution: "json-2-csv@npm:5.5.9"
dependencies:
deeks: 3.1.0
doc-path: 4.1.1
checksum: 109b7636ba3ef9b8c53580f89280568e1ecd106b6328f44636869a62eac1f9f2db7a52de0eee04832fa20d4bd9c592ad3fe1a4cf06af0eee3ee612b85f29ed75
languageName: node
linkType: hard
"json-buffer@npm:3.0.1":
version: 3.0.1
resolution: "json-buffer@npm:3.0.1"