diff --git a/.changeset/three-masks-bathe.md b/.changeset/three-masks-bathe.md new file mode 100644 index 0000000000..1b1ec7bc97 --- /dev/null +++ b/.changeset/three-masks-bathe.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/utils": patch +"integration-tests-http": patch +--- + +feat: define validators and use normalize-products step diff --git a/integration-tests/http/__tests__/product/admin/__fixtures__/product-with-categories.csv b/integration-tests/http/__tests__/product/admin/__fixtures__/product-with-categories.csv index 4a3524cce0..936065f1af 100644 --- a/integration-tests/http/__tests__/product/admin/__fixtures__/product-with-categories.csv +++ b/integration-tests/http/__tests__/product/admin/__fixtures__/product-with-categories.csv @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/integration-tests/http/__tests__/product/admin/__fixtures__/exported-products-comma.csv b/integration-tests/http/__tests__/product/admin/__fixtures__/products-comma.csv similarity index 57% rename from integration-tests/http/__tests__/product/admin/__fixtures__/exported-products-comma.csv rename to integration-tests/http/__tests__/product/admin/__fixtures__/products-comma.csv index 24d233e30c..e419276148 100644 --- a/integration-tests/http/__tests__/product/admin/__fixtures__/exported-products-comma.csv +++ b/integration-tests/http/__tests__/product/admin/__fixtures__/products-comma.csv @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/integration-tests/http/__tests__/product/admin/__fixtures__/exported-products-semicolon.csv b/integration-tests/http/__tests__/product/admin/__fixtures__/products-semicolon.csv similarity index 57% rename from integration-tests/http/__tests__/product/admin/__fixtures__/exported-products-semicolon.csv rename to integration-tests/http/__tests__/product/admin/__fixtures__/products-semicolon.csv index 9b0d8e7b47..4bb65929f8 100644 --- a/integration-tests/http/__tests__/product/admin/__fixtures__/exported-products-semicolon.csv +++ b/integration-tests/http/__tests__/product/admin/__fixtures__/products-semicolon.csv @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/integration-tests/http/__tests__/product/admin/__snapshots__/product-export.spec.ts.snap b/integration-tests/http/__tests__/product/admin/__snapshots__/product-export.spec.ts.snap new file mode 100644 index 0000000000..e556622132 --- /dev/null +++ b/integration-tests/http/__tests__/product/admin/__snapshots__/product-export.spec.ts.snap @@ -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": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description +test line 2", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "base-product", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant", + "Variant Upc": "", + "Variant Updated At": "", + "Variant Variant Rank": 0, + "Variant Weight": "", + "Variant Width": "", + }, + { + "Product Collection Id": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description +test line 2", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "base-product", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant 2", + "Variant Upc": "", + "Variant Updated At": "", + "Variant Variant Rank": 0, + "Variant Weight": "", + "Variant Width": "", + }, + { + "Product Collection Id": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "proposed-product", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant", + "Variant Upc": "", + "Variant Updated At": "", + "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": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "proposed-product", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant", + "Variant Upc": "", + "Variant Updated At": "", + "Variant Variant Rank": 0, + "Variant Weight": "", + "Variant Width": "", + }, +] +`; + +exports[` POST /admin/products/export should export a csv file with categories 1`] = ` +[ + { + "Product Category 1": "", + "Product Collection Id": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description +test line 2", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "base-product", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant", + "Variant Upc": "", + "Variant Updated At": "", + "Variant Variant Rank": 0, + "Variant Weight": "", + "Variant Width": "", + }, + { + "Product Category 1": "", + "Product Collection Id": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description +test line 2", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "base-product", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant 2", + "Variant Upc": "", + "Variant Updated At": "", + "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": "", + "Product Created At": "", + "Product Deleted At": "", + "Product Description": "test-product-description", + "Product Discountable": true, + "Product External Id": "", + "Product Handle": "product-with-prices", + "Product Height": "", + "Product Hs Code": "", + "Product 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": "", + "Product Updated At": "", + "Product Weight": "", + "Product Width": "", + "Variant Allow Backorder": false, + "Variant Barcode": "", + "Variant Created At": "", + "Variant Deleted At": "", + "Variant Ean": "", + "Variant Height": "", + "Variant Hs Code": "", + "Variant 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": "", + "Variant Sku": "", + "Variant Title": "Test variant", + "Variant Upc": "", + "Variant Updated At": "", + "Variant Variant Rank": 0, + "Variant Weight": "", + "Variant Width": "", + }, +] +`; diff --git a/integration-tests/http/__tests__/product/admin/product-export.spec.ts b/integration-tests/http/__tests__/product/admin/product-export.spec.ts index 2f96e7b4d4..ce46bd63af 100644 --- a/integration-tests/http/__tests__/product/admin/product-export.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-export.spec.ts @@ -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"), - "" - ) - fixturesContent = fixturesContent.replace( - new RegExp(`${prefix}\\w*\\d*`, "g"), - "" - ) + return csvRows.reduce((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] = "" + } + if (col.includes("Id") || col.startsWith("Product Category ")) { + rowCopy[col] = "" + } + }) + + 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, "") - fixturesContent = fixturesContent.replace(dateRegex, "") - - 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() }) }) }, diff --git a/integration-tests/http/__tests__/product/admin/product-import.spec.ts b/integration-tests/http/__tests__/product/admin/product-import.spec.ts index 7ba3772f28..53006ac36d 100644 --- a/integration-tests/http/__tests__/product/admin/product-import.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-import.spec.ts @@ -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"' ) }) }) diff --git a/integration-tests/http/package.json b/integration-tests/http/package.json index 712d1c3be2..6c194602c7 100644 --- a/integration-tests/http/package.json +++ b/integration-tests/http/package.json @@ -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" } } diff --git a/packages/core/core-flows/src/product/steps/generate-product-csv.ts b/packages/core/core-flows/src/product/steps/generate-product-csv.ts index 997409d0f7..f025c5ec45 100644 --- a/packages/core/core-flows/src/product/steps/generate-product-csv.ts +++ b/packages/core/core-flows/src/product/steps/generate-product-csv.ts @@ -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 ) }, diff --git a/packages/core/core-flows/src/product/steps/group-products-for-batch.ts b/packages/core/core-flows/src/product/steps/group-products-for-batch.ts deleted file mode 100644 index f62c4e0ef0..0000000000 --- a/packages/core/core-flows/src/product/steps/group-products-for-batch.ts +++ /dev/null @@ -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(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, - ) - } -) diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index b891bccbbf..9f299c60b6 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/product/steps/normalize-products-v1.ts b/packages/core/core-flows/src/product/steps/normalize-products.ts similarity index 61% rename from packages/core/core-flows/src/product/steps/normalize-products-v1.ts rename to packages/core/core-flows/src/product/steps/normalize-products.ts index 7848bb6b08..ee4c85d234 100644 --- a/packages/core/core-flows/src/product/steps/normalize-products-v1.ts +++ b/packages/core/core-flows/src/product/steps/normalize-products.ts @@ -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[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) + }) } ) diff --git a/packages/core/core-flows/src/product/steps/parse-product-csv.ts b/packages/core/core-flows/src/product/steps/parse-product-csv.ts index 79657c19c3..12fd5277f3 100644 --- a/packages/core/core-flows/src/product/steps/parse-product-csv.ts +++ b/packages/core/core-flows/src/product/steps/parse-product-csv.ts @@ -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. diff --git a/packages/core/core-flows/src/product/utlils/csvtojson.ts b/packages/core/core-flows/src/product/utils/csvtojson.ts similarity index 100% rename from packages/core/core-flows/src/product/utlils/csvtojson.ts rename to packages/core/core-flows/src/product/utils/csvtojson.ts diff --git a/packages/core/core-flows/src/product/utlils/index.ts b/packages/core/core-flows/src/product/utils/index.ts similarity index 100% rename from packages/core/core-flows/src/product/utlils/index.ts rename to packages/core/core-flows/src/product/utils/index.ts diff --git a/packages/core/core-flows/src/product/utlils/jsontocsv.ts b/packages/core/core-flows/src/product/utils/jsontocsv.ts similarity index 100% rename from packages/core/core-flows/src/product/utlils/jsontocsv.ts rename to packages/core/core-flows/src/product/utils/jsontocsv.ts diff --git a/packages/core/core-flows/src/product/workflows/import-products.ts b/packages/core/core-flows/src/product/workflows/import-products.ts index 87bfd5b1a4..e89069c028 100644 --- a/packages/core/core-flows/src/product/workflows/import-products.ts +++ b/packages/core/core-flows/src/product/workflows/import-products.ts @@ -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 ): WorkflowResponse => { - const products = parseProductCsvStep(input.fileContent) - const batchRequest = groupProductsForBatchStep(products) + const batchRequest = normalizeCsvStep(input.fileContent) const summary = transform({ batchRequest }, (data) => { return { diff --git a/packages/core/utils/src/product/__tests__/csv-normalizer.spec.ts b/packages/core/utils/src/product/__tests__/csv-normalizer.spec.ts index 3dadfd689c..7bee7957bd 100644 --- a/packages/core/utils/src/product/__tests__/csv-normalizer.spec.ts +++ b/packages/core/utils/src/product/__tests__/csv-normalizer.spec.ts @@ -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, }, }, } diff --git a/packages/core/utils/src/product/csv-normalizer.ts b/packages/core/utils/src/product/csv-normalizer.ts index b378cc28c9..97bfc2a9e8 100644 --- a/packages/core/utils/src/product/csv-normalizer.ts +++ b/packages/core/utils/src/product/csv-normalizer.ts @@ -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) } }) diff --git a/packages/core/utils/src/product/enums.ts b/packages/core/utils/src/product/enums.ts new file mode 100644 index 0000000000..a00fdcf0d0 --- /dev/null +++ b/packages/core/utils/src/product/enums.ts @@ -0,0 +1,6 @@ +export enum ProductStatus { + DRAFT = "draft", + PROPOSED = "proposed", + PUBLISHED = "published", + REJECTED = "rejected", +} diff --git a/packages/core/utils/src/product/index.ts b/packages/core/utils/src/product/index.ts index 473ea9ab14..d423112c78 100644 --- a/packages/core/utils/src/product/index.ts +++ b/packages/core/utils/src/product/index.ts @@ -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" diff --git a/packages/core/utils/src/product/validators.ts b/packages/core/utils/src/product/validators.ts new file mode 100644 index 0000000000..accdc2ee49 --- /dev/null +++ b/packages/core/utils/src/product/validators.ts @@ -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() diff --git a/yarn.lock b/yarn.lock index a92902e23f..d039d0354b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"