From f6277c5471f36f80a702b1fec2f729a3b6bd4ce9 Mon Sep 17 00:00:00 2001 From: Deb Mitchell Date: Wed, 29 Apr 2026 18:32:39 +0100 Subject: [PATCH 1/2] Fix #7622 Shopify product export child-item variant handling (#7689) Fixes #7622 Fixes an issue where product export could continue processing variants on a child item after handling an existing Shopify variant created through "Add Item as Shopify Variant". - Re-fetch the parent item before the item variant loop in `Shpfy Product Export`. - Add a regression test for the child-item variant scenario. - Add a test subscriber that mocks Shopify GraphQL responses and verifies that no unexpected variant create call is made. - Validated in a clean local W1 container. - Passed `Codeunit 139616 Shpfy Product Export 7622 Test`. Fixes [AB#632757](https://dynamicssmb2.visualstudio.com/1fcb79e7-ab07-432a-a3c6-6cf5a88ba4a5/_workitems/edit/632757) --- .../Codeunits/ShpfyProductExport.Codeunit.al | 4 + .../Products/ProductUpdateResponse.txt | 1 + .../ProductVariantsBulkUpdateResponse.txt | 1 + .../ShpfyCreateProductTest.Codeunit.al | 147 ++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 src/Apps/W1/Shopify/Test/.resources/Products/ProductUpdateResponse.txt create mode 100644 src/Apps/W1/Shopify/Test/.resources/Products/ProductVariantsBulkUpdateResponse.txt diff --git a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al index 3476ce46f0..f1337c048d 100644 --- a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al @@ -657,6 +657,10 @@ codeunit 30178 "Shpfy Product Export" UpdateProductVariant(ShopifyVariant, Item, ItemVariant, TempCurrVariant); end; until ShopifyVariant.Next() = 0; + // Re-fetch the parent product's item, as the loop above may have overwritten + // the Item variable with a child item from "Add Item as Shopify Variant". + if not Item.GetBySystemId(ShopifyProduct."Item SystemId") then + exit; ItemVariant.SetRange("Item No.", Item."No."); ItemUnitofMeasure.SetRange("Item No.", Item."No."); if ItemVariant.FindSet(false) then diff --git a/src/Apps/W1/Shopify/Test/.resources/Products/ProductUpdateResponse.txt b/src/Apps/W1/Shopify/Test/.resources/Products/ProductUpdateResponse.txt new file mode 100644 index 0000000000..407fb106f4 --- /dev/null +++ b/src/Apps/W1/Shopify/Test/.resources/Products/ProductUpdateResponse.txt @@ -0,0 +1 @@ +{"data":{"productUpdate":{"product":{"onlineStoreUrl":"","onlineStorePreviewUrl":"","updatedAt":"2026-01-01T00:00:00Z"},"userErrors":[]}}} \ No newline at end of file diff --git a/src/Apps/W1/Shopify/Test/.resources/Products/ProductVariantsBulkUpdateResponse.txt b/src/Apps/W1/Shopify/Test/.resources/Products/ProductVariantsBulkUpdateResponse.txt new file mode 100644 index 0000000000..e27c80a41b --- /dev/null +++ b/src/Apps/W1/Shopify/Test/.resources/Products/ProductVariantsBulkUpdateResponse.txt @@ -0,0 +1 @@ +{"data":{"productVariantsBulkUpdate":{"productVariants":[],"userErrors":[]}}} \ No newline at end of file diff --git a/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al index 415db40208..97eb546806 100644 --- a/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al +++ b/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al @@ -15,11 +15,17 @@ codeunit 139601 "Shpfy Create Product Test" Subtype = Test; TestType = IntegrationTest; TestPermissions = Disabled; + TestHttpRequestPolicy = BlockOutboundRequests; var + ExportShop: Record "Shpfy Shop"; Any: Codeunit Any; LibraryAssert: Codeunit "Library Assert"; + OutboundHttpRequests: Codeunit "Library - Variable Storage"; + LibraryRandom: Codeunit "Library - Random"; + ShpfyInitializeTest: Codeunit "Shpfy Initialize Test"; + ExportIsInitialized: Boolean; [Test] procedure UnitTestCreateTempProductFromItem() @@ -2958,4 +2964,145 @@ codeunit 139601 "Shpfy Create Product Test" until TempShopifyVariant.Next() = 0; end; + + [Test] + [HandlerFunctions('ProductExportChildItemVariantHttpHandler')] + procedure UnitTestProductExportDoesNotCreateVariantsForChildItemVariants() + var + ParentItem: Record Item; + ChildItem: Record Item; + ChildItemVariantMapped: Record "Item Variant"; + ChildItemVariantUnmapped: Record "Item Variant"; + ShopifyProduct: Record "Shpfy Product"; + ShopifyVariant: Record "Shpfy Variant"; + ProductExport: Codeunit "Shpfy Product Export"; + ProductInitTest: Codeunit "Shpfy Product Init Test"; + begin + // [SCENARIO] Product Export must not create additional Shopify variants + // [SCENARIO] for unmapped child-item variants when a child item was added as Shopify variant. + InitializeProductExport(); + + // [GIVEN] Register Expected Outbound API Requests. + RegExpectedOutboundHttpRequestsForProductExport(); + + // [GIVEN] A parent item (without BC variants) and a child item with two BC variants. + ParentItem := ProductInitTest.CreateItem(ExportShop."Item Templ. Code", Any.DecimalInRange(10, 100, 2), Any.DecimalInRange(100, 500, 2), false); + ChildItem := ProductInitTest.CreateItem(ExportShop."Item Templ. Code", Any.DecimalInRange(10, 100, 2), Any.DecimalInRange(100, 500, 2), false); + ChildItemVariantMapped := CreateItemVariantForExport(ChildItem, 'MAP'); + ChildItemVariantUnmapped := CreateItemVariantForExport(ChildItem, 'UNMAPPED'); + + // [GIVEN] A Shopify product mapped to the parent item and one existing Shopify variant + // [GIVEN] mapped to only one child item variant. + ShopifyProduct := CreateShopifyProductForExport(ParentItem.SystemId); + ShopifyVariant := CreateMappedShopifyVariantForExport(ShopifyProduct.Id, ChildItem.SystemId, ChildItemVariantMapped.SystemId); + + // [WHEN] Product export runs for the shop. + ProductExport.SetShop(ExportShop); + ExportShop.SetRange(Code, ExportShop.Code); + ProductExport.Run(ExportShop); + OutboundHttpRequests.AssertEmpty(); + + // [THEN] No new Shopify variant record is created for the unmapped child item variant. + ShopifyVariant.Reset(); + ShopifyVariant.SetRange("Product Id", ShopifyProduct.Id); + ShopifyVariant.SetRange("Item SystemId", ChildItem.SystemId); + ShopifyVariant.SetRange("Item Variant SystemId", ChildItemVariantUnmapped.SystemId); + LibraryAssert.IsTrue(ShopifyVariant.IsEmpty(), 'Unexpected Shopify variant record created for unmapped child item variant.'); + end; + + [HttpClientHandler] + internal procedure ProductExportChildItemVariantHttpHandler(Request: TestHttpRequestMessage; var Response: TestHttpResponseMessage): Boolean + var + ProductUpdateResponseTok: Label 'Products/ProductUpdateResponse.txt', Locked = true; + ProductVariantsBulkUpdateResponseTok: Label 'Products/ProductVariantsBulkUpdateResponse.txt', Locked = true; + UnexpectedAPICallsErr: Label 'More than expected API calls to Shopify detected.'; + begin + if not ShpfyInitializeTest.VerifyRequestUrl(Request.Path, ExportShop."Shopify URL") then + exit(true); + + case OutboundHttpRequests.Length() of + 2: + LoadProductExportResourceIntoHttpResponse(ProductUpdateResponseTok, Response); + 1: + LoadProductExportResourceIntoHttpResponse(ProductVariantsBulkUpdateResponseTok, Response); + 0: + Error(UnexpectedAPICallsErr); + end; + exit(false); + end; + + local procedure InitializeProductExport() + var + AccessToken: SecretText; + begin + Any.SetDefaultSeed(); + OutboundHttpRequests.Clear(); + if ExportIsInitialized then + exit; + + ExportShop := ShpfyInitializeTest.CreateShop(); + ExportShop."Can Update Shopify Products" := true; + ExportShop."Product Metafields To Shopify" := false; + ExportShop.Modify(); + Commit(); + + AccessToken := LibraryRandom.RandText(20); + ShpfyInitializeTest.RegisterAccessTokenForShop(ExportShop.GetStoreName(), AccessToken); + + ExportIsInitialized := true; + end; + + local procedure RegExpectedOutboundHttpRequestsForProductExport() + begin + OutboundHttpRequests.Enqueue('GQL Update Product'); + OutboundHttpRequests.Enqueue('GQL Update Product Variants'); + end; + + local procedure LoadProductExportResourceIntoHttpResponse(ResourceText: Text; var Response: TestHttpResponseMessage) + begin + Response.Content.WriteFrom(NavApp.GetResourceAsText(ResourceText, TextEncoding::UTF8)); + OutboundHttpRequests.DequeueText(); + end; + + local procedure CreateItemVariantForExport(Item: Record Item; VariantCodePrefix: Text): Record "Item Variant" + var + ItemVariant: Record "Item Variant"; + begin + ItemVariant.Init(); + ItemVariant.Validate("Item No.", Item."No."); + ItemVariant.Code := CopyStr(VariantCodePrefix + Any.AlphabeticText(5), 1, MaxStrLen(ItemVariant.Code)); + ItemVariant.Description := CopyStr(Any.AlphabeticText(20), 1, MaxStrLen(ItemVariant.Description)); + ItemVariant.Insert(); + exit(ItemVariant); + end; + + local procedure CreateShopifyProductForExport(ItemSystemId: Guid): Record "Shpfy Product" + var + ShopifyProduct: Record "Shpfy Product"; + begin + ShopifyProduct.Init(); + ShopifyProduct.Id := Any.IntegerInRange(10000, 99999); + ShopifyProduct."Shop Code" := ExportShop.Code; + ShopifyProduct."Item SystemId" := ItemSystemId; + ShopifyProduct.Title := CopyStr(Any.AlphabeticText(20), 1, MaxStrLen(ShopifyProduct.Title)); + ShopifyProduct.Insert(); + exit(ShopifyProduct); + end; + + local procedure CreateMappedShopifyVariantForExport(ProductId: BigInteger; ItemSystemId: Guid; ItemVariantSystemId: Guid): Record "Shpfy Variant" + var + ShopifyVariant: Record "Shpfy Variant"; + begin + ShopifyVariant.Init(); + ShopifyVariant.Id := Any.IntegerInRange(100000, 999999); + ShopifyVariant."Shop Code" := ExportShop.Code; + ShopifyVariant."Product Id" := ProductId; + ShopifyVariant."Item SystemId" := ItemSystemId; + ShopifyVariant."Item Variant SystemId" := ItemVariantSystemId; + ShopifyVariant."Option 1 Name" := 'Variant'; + ShopifyVariant."Option 1 Value" := CopyStr(Any.AlphabeticText(10), 1, MaxStrLen(ShopifyVariant."Option 1 Value")); + ShopifyVariant.Title := CopyStr(Any.AlphabeticText(20), 1, MaxStrLen(ShopifyVariant.Title)); + ShopifyVariant.Insert(); + exit(ShopifyVariant); + end; } From e878f5e5233caa9c50beb52ce18d0673bc4bd8a1 Mon Sep 17 00:00:00 2001 From: Deb Mitchell Date: Thu, 18 Jun 2026 17:40:45 +0100 Subject: [PATCH 2/2] fix: disable singleton CommunicationMgt event mocking in export test [HttpClientHandler] requires HttpClient.Send() to be called, but CreateShop() sets IsTestInProgress = true on the SingleInstance CommunicationMgt, redirecting HTTP calls through the old OnClientSend/OnGetContent event system. Without subscribers to those events the response is empty, causing 'no JSON' for every productUpdate call. Call SetTestInProgress(false) in InitializeProductExport() before the export runs, matching the pattern already used in ShpfyCreateItemAPITest. --- .../Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al index 97eb546806..81e43dac55 100644 --- a/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al +++ b/src/Apps/W1/Shopify/Test/Products/ShpfyCreateProductTest.Codeunit.al @@ -3033,10 +3033,15 @@ codeunit 139601 "Shpfy Create Product Test" local procedure InitializeProductExport() var + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; AccessToken: SecretText; begin Any.SetDefaultSeed(); OutboundHttpRequests.Clear(); + // CreateShop() sets IsTestInProgress = true on the singleton CommunicationMgt, + // which redirects HTTP calls through event mocking instead of HttpClient.Send(). + // Disable that so [HttpClientHandler] can intercept the requests instead. + CommunicationMgt.SetTestInProgress(false); if ExportIsInitialized then exit;