Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Shopify Connector - Copilot Instructions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this file.


## Pre-submission checklist (REQUIRED before any git push or PR action)

Before pushing a branch or creating/updating a PR, always:

1. **Compile both apps** - run `BuildShopify.ps1` and confirm zero errors (warnings are OK).
2. **Run the new/changed test codeunit** - run `Run-TestsInBcContainer` for the specific codeunit and confirm all tests pass.
3. **Run the regression codeunit** - always also run codeunit 139601 (Shopify Product Export tests) to confirm no regression.
4. **Revert local-only version bumps** - `app.json` version fields must be back at their committed values before committing (use `git checkout HEAD -- **/app.json`).

If any test fails, fix the code, rebuild, and rerun tests before proceeding. Never push with a known test failure.

## Build and test commands

```powershell
# Build (compile App + Test .app packages)
& powershell.exe -File 'C:\usr\admin\AL\BuildShopify.ps1'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Security} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Local machine path hardcoded in shared file

The path C:\usr\admin\AL\BuildShopify.ps1 is a local developer machine path committed into a repository-wide Copilot instructions file, exposing the developer's directory layout and making the instructions non-functional for other contributors.

Recommendation:

  • Replace the absolute path with a repository-relative path, or document that contributors must update the path to match their local checkout.
Suggested change
& powershell.exe -File 'C:\usr\admin\AL\BuildShopify.ps1'
& powershell.exe -File (Join-Path $PSScriptRoot '../BuildShopify.ps1')

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why


# Run tests (use current container IP - check with: docker inspect BC-29 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}")
$cred = New-Object PSCredential('admin', (ConvertTo-SecureString 'P@ssw0rd' -AsPlainText -Force))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🔴\ Critical\ Severity\ —\ Security} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Plaintext password committed to repository

A hardcoded password (P@ssw0rd) is committed in a PSCredential constructor call. Anyone with read access to the repository — including future contributors or if the repo is ever made public — can extract this credential.

Recommendation:

  • Remove the hardcoded password entirely. Use environment variables, GitHub Actions secrets, or a secrets manager instead. Replace with a placeholder such as (ConvertTo-SecureString $env:BC_ADMIN_PASSWORD -AsPlainText -Force).
Suggested change
$cred = New-Object PSCredential('admin', (ConvertTo-SecureString 'P@ssw0rd' -AsPlainText -Force))
$cred = New-Object PSCredential('admin', (ConvertTo-SecureString $env:BC_ADMIN_PASSWORD -AsPlainText -Force))

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

$ip = docker inspect BC-29 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
Run-TestsInBcContainer -containerName 'BC-29' -credential $cred -testCodeunit <ID> -returnTrueIfAllPassed -useUrl "http://$($ip)/BC"
```

> Note: The container IP changes on restart. Always re-check it before running tests.
> The test runner connects to port 80 (web server), not port 7046 directly.

## BC Container notes

- Container name: `BC-29`
- Credentials: `admin / P@ssw0rd`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🔴\ Critical\ Severity\ —\ Security} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Credentials documented in plaintext in repo

The line - Credentials: \admin / P@ssw0rd`` embeds the same container password in plain markdown, making it visible in PR diffs, blame history, and any repository mirror forever.

Recommendation:

  • Remove the credential line entirely. Reference your team's internal wiki or a secret store for dev environment credentials instead.
Suggested change
- Credentials: `admin / P@ssw0rd`
- Credentials: See team wiki / 1Password vault (do not store passwords in source files)

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

- After a container restart, apps may need to be reinstalled: `Install-BcContainerApp -containerName 'BC-29' -appName '...' -appVersion '...'`
- Shared folder for build output: `C:\ProgramData\BcContainerHelper\Extensions\BC-29\build\`
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ codeunit 30178 "Shpfy Product Export"
var
Product: Record "Shpfy Product";
ItemAsVariant: Boolean;
JustResolvedOption1Name: Boolean;
CachedOption1Name: Text[50];
begin
if Shop."Sync Prices" or OnlyUpdatePrice then
if Item.Blocked or Item."Sales Blocked" then
Expand Down Expand Up @@ -433,9 +435,13 @@ codeunit 30178 "Shpfy Product Export"
ShopifyVariant."Tariff No." := Item."Tariff No.";
ShopifyVariant."Country/Region of Origin Code" := GetCountryISOCode(Item."Country/Region of Origin Code");
end;
if ShopifyVariant."Option 1 Name" = '' then
ShopifyVariant."Option 1 Name" := 'Variant';
if ShopifyVariant."Option 1 Name" = 'Variant' then
Comment thread
onbuyuka marked this conversation as resolved.
if ShopifyVariant."Option 1 Name" = '' then begin
if CachedOption1Name = '' then
CachedOption1Name := ResolveOption1Name(ShopifyVariant."Product Id", Shop.Code);
ShopifyVariant."Option 1 Name" := CachedOption1Name;
JustResolvedOption1Name := true;
end;
if (ShopifyVariant."Option 1 Name" = 'Variant') or JustResolvedOption1Name then
if ItemAsVariant then
ShopifyVariant."Option 1 Value" := Item."No."
else
Expand Down Expand Up @@ -881,6 +887,32 @@ codeunit 30178 "Shpfy Product Export"
end;
end;

local procedure ResolveOption1Name(ProductId: BigInteger; ShopCode: Code[20]): Text[50]
var
ShpfyVariant: Record "Shpfy Variant";
VariantOption1Name: Text[50];
FirstVariantName: Boolean;
begin
ShpfyVariant.SetRange("Shop Code", ShopCode);
ShpfyVariant.SetRange("Product Id", ProductId);
Comment thread
onbuyuka marked this conversation as resolved.
if not ShpfyVariant.FindSet() then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

ResolveOption1Name reads all fields unnecessarily

ResolveOption1Name calls FindSet() on "Shpfy Variant" without first calling SetLoadFields. For products with many variants, this fetches all columns from the table when only "Option 1 Name" is needed, increasing I/O and memory pressure.

Recommendation:

  • Add ShpfyVariant.SetLoadFields("Option 1 Name") before FindSet() to limit the data read from the database.
Suggested change
if not ShpfyVariant.FindSet() then
ShpfyVariant.SetRange("Shop Code", ShopCode);
ShpfyVariant.SetRange("Product Id", ProductId);
ShpfyVariant.SetLoadFields("Option 1 Name");
if not ShpfyVariant.FindSet() then
exit('Variant');

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

exit('Variant');
FirstVariantName := true;
repeat
if ShpfyVariant."Option 1 Name" <> '' then
if FirstVariantName then begin
VariantOption1Name := ShpfyVariant."Option 1 Name";
FirstVariantName := false;
end
else
if VariantOption1Name <> ShpfyVariant."Option 1 Name" then
exit('Variant');
until ShpfyVariant.Next() = 0;
if not FirstVariantName then
exit(VariantOption1Name);
exit('Variant');
end;

local procedure RevertVariantChanges(VariantId: BigInteger)
var
ShopifyVariant: Record "Shpfy Variant";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------

namespace Microsoft.Integration.Shopify.Test;

using Microsoft.Integration.Shopify;
using Microsoft.Inventory.Item;
using System.TestLibraries.Utilities;

/// <summary>
/// Tests for Issue #7724: Option 1 Name resolution in FillInProductVariantData.
/// </summary>
codeunit 139649 "Shpfy Variant Option1Name Test"
{
Subtype = Test;
TestType = IntegrationTest;
TestPermissions = Disabled;
TestHttpRequestPolicy = BlockOutboundRequests;

var
Shop: Record "Shpfy Shop";
InitializeTest: Codeunit "Shpfy Initialize Test";
LibraryAssert: Codeunit "Library Assert";
IsInitialized: Boolean;

[Test]
procedure UnitTestOption1NameInheritedFromShopifyOriginProduct()
var
Item: Record Item;
ItemVariant: Record "Item Variant";
ShopifyProduct: Record "Shpfy Product";
ExistingVariant: Record "Shpfy Variant";
NewVariant: Record "Shpfy Variant";
ProductExport: Codeunit "Shpfy Product Export";
begin
// [SCENARIO #7724] Shopify-origin product with one existing variant having Option 1 Name = 'Size'
// -> new BC variant export inherits 'Size' and Option 1 Value = ItemVariant.Code.
Initialize();

// [GIVEN] An item with a variant
CreateItem(Item);
CreateItemVariant(Item, ItemVariant);

// [GIVEN] A Shopify product with one existing variant carrying Option 1 Name = 'Size'
CreateShopifyProduct(ShopifyProduct, Item.SystemId);
CreateShopifyVariant(ExistingVariant, ShopifyProduct, Item.SystemId, 'Size', 'Test');

// [GIVEN] A new blank Shpfy Variant for the same product (being created from BC)
CreateBlankShopifyVariant(NewVariant, ShopifyProduct, Item.SystemId);

// [WHEN] FillInProductVariantData is called
ProductExport.SetShop(Shop);
ProductExport.FillInProductVariantData(NewVariant, Item, ItemVariant);

// [THEN] Option 1 Name is resolved to 'Size' (from existing Shopify variant)
LibraryAssert.AreEqual('Size', NewVariant."Option 1 Name", 'Option 1 Name should be inherited from existing Shopify variant');

// [THEN] Option 1 Value is set to ItemVariant.Code
LibraryAssert.AreEqual(ItemVariant.Code, NewVariant."Option 1 Value", 'Option 1 Value should be set to ItemVariant.Code');
end;

[Test]
procedure UnitTestOption1NameDefaultsToVariantForBCOriginProduct()
var
Item: Record Item;
ItemVariant: Record "Item Variant";
ShopifyProduct: Record "Shpfy Product";
ExistingVariant: Record "Shpfy Variant";
NewVariant: Record "Shpfy Variant";
ProductExport: Codeunit "Shpfy Product Export";
begin
// [SCENARIO #7724] BC-origin product where existing variants already carry Option 1 Name = 'Variant'
// -> new variant still defaults to 'Variant' (regression guard).
Initialize();

// [GIVEN] An item with a variant
CreateItem(Item);
CreateItemVariant(Item, ItemVariant);

// [GIVEN] A Shopify product with an existing BC-origin variant (Option 1 Name = 'Variant')
CreateShopifyProduct(ShopifyProduct, Item.SystemId);
CreateShopifyVariant(ExistingVariant, ShopifyProduct, Item.SystemId, 'Variant', 'VAR1');

// [GIVEN] A new blank Shpfy Variant for the same product
CreateBlankShopifyVariant(NewVariant, ShopifyProduct, Item.SystemId);

// [WHEN] FillInProductVariantData is called
ProductExport.SetShop(Shop);
ProductExport.FillInProductVariantData(NewVariant, Item, ItemVariant);

// [THEN] Option 1 Name stays 'Variant'
LibraryAssert.AreEqual('Variant', NewVariant."Option 1 Name", 'Option 1 Name should default to Variant for BC-origin products');

// [THEN] Option 1 Value is set to ItemVariant.Code
LibraryAssert.AreEqual(ItemVariant.Code, NewVariant."Option 1 Value", 'Option 1 Value should be set to ItemVariant.Code');
end;

[Test]
procedure UnitTestOption1NameDefaultsToVariantWhenNoExistingVariants()
var
Item: Record Item;
ItemVariant: Record "Item Variant";
ShopifyProduct: Record "Shpfy Product";
NewVariant: Record "Shpfy Variant";
ProductExport: Codeunit "Shpfy Product Export";
begin
// [SCENARIO #7724] Product with no existing Shpfy Variant rows -> still defaults to 'Variant'.
Initialize();

// [GIVEN] An item with a variant
CreateItem(Item);
CreateItemVariant(Item, ItemVariant);

// [GIVEN] A Shopify product with no existing variants in BC
CreateShopifyProduct(ShopifyProduct, Item.SystemId);

// [GIVEN] A new blank Shpfy Variant (only one in table for this product)
CreateBlankShopifyVariant(NewVariant, ShopifyProduct, Item.SystemId);

// [WHEN] FillInProductVariantData is called
ProductExport.SetShop(Shop);
ProductExport.FillInProductVariantData(NewVariant, Item, ItemVariant);

// [THEN] Option 1 Name defaults to 'Variant'
LibraryAssert.AreEqual('Variant', NewVariant."Option 1 Name", 'Option 1 Name should default to Variant when no existing variants exist');

// [THEN] Option 1 Value is set to ItemVariant.Code
LibraryAssert.AreEqual(ItemVariant.Code, NewVariant."Option 1 Value", 'Option 1 Value should be set to ItemVariant.Code');
end;

[Test]
procedure UnitTestOption1NameDefaultsToVariantWhenAmbiguous()
var
Item: Record Item;
ItemVariant: Record "Item Variant";
ShopifyProduct: Record "Shpfy Product";
ExistingVariant1: Record "Shpfy Variant";
ExistingVariant2: Record "Shpfy Variant";
NewVariant: Record "Shpfy Variant";
ProductExport: Codeunit "Shpfy Product Export";
begin
// [SCENARIO #7724] Two existing variants with different Option 1 Names (ambiguous) -> falls back to 'Variant'.
Initialize();

// [GIVEN] An item with a variant
CreateItem(Item);
CreateItemVariant(Item, ItemVariant);

// [GIVEN] A Shopify product with two existing variants having conflicting Option 1 Names
CreateShopifyProduct(ShopifyProduct, Item.SystemId);
CreateShopifyVariant(ExistingVariant1, ShopifyProduct, Item.SystemId, 'Color', 'Red');
CreateShopifyVariant(ExistingVariant2, ShopifyProduct, Item.SystemId, 'Size', 'Large');

// [GIVEN] A new blank Shpfy Variant for the same product
CreateBlankShopifyVariant(NewVariant, ShopifyProduct, Item.SystemId);

// [WHEN] FillInProductVariantData is called
ProductExport.SetShop(Shop);
ProductExport.FillInProductVariantData(NewVariant, Item, ItemVariant);

// [THEN] Option 1 Name falls back to 'Variant' due to ambiguity
LibraryAssert.AreEqual('Variant', NewVariant."Option 1 Name", 'Option 1 Name should fall back to Variant when existing variants have conflicting names');

// [THEN] Option 1 Value is set to ItemVariant.Code
LibraryAssert.AreEqual(ItemVariant.Code, NewVariant."Option 1 Value", 'Option 1 Value should be set to ItemVariant.Code');
end;

[Test]
procedure UnitTestOption1NameAndValuePreservedOnUpdatePath()
var
Item: Record Item;
ItemVariant: Record "Item Variant";
ShopifyProduct: Record "Shpfy Product";
ExistingVariant: Record "Shpfy Variant";
ProductExport: Codeunit "Shpfy Product Export";
begin
// [SCENARIO #7724] Update path: existing Shpfy Variant with a custom Option 1 Name (e.g. 'Color')
// -> name and value remain untouched after FillInProductVariantData.
Initialize();

// [GIVEN] An item with a variant
CreateItem(Item);
CreateItemVariant(Item, ItemVariant);

// [GIVEN] A Shopify product with one fully-populated variant (already imported from Shopify)
CreateShopifyProduct(ShopifyProduct, Item.SystemId);
CreateShopifyVariant(ExistingVariant, ShopifyProduct, Item.SystemId, 'Color', 'Red');

// [WHEN] FillInProductVariantData is called on the already-populated variant (update path)
ProductExport.SetShop(Shop);
ProductExport.FillInProductVariantData(ExistingVariant, Item, ItemVariant);

// [THEN] Option 1 Name is NOT overwritten - it keeps 'Color'
LibraryAssert.AreEqual('Color', ExistingVariant."Option 1 Name", 'Option 1 Name must not be overwritten on the update path');

// [THEN] Option 1 Value is NOT overwritten - it keeps 'Red'
LibraryAssert.AreEqual('Red', ExistingVariant."Option 1 Value", 'Option 1 Value must not be overwritten on the update path');
end;

local procedure Initialize()
begin
if IsInitialized then
exit;

IsInitialized := true;
Shop := InitializeTest.CreateShop();
Commit();
end;

local procedure CreateItem(var Item: Record Item)
var
ProductInitTest: Codeunit "Shpfy Product Init Test";
begin
Item := ProductInitTest.CreateItem();
end;

local procedure CreateItemVariant(Item: Record Item; var ItemVariant: Record "Item Variant")
begin
Comment thread
onbuyuka marked this conversation as resolved.
ItemVariant.Init();
ItemVariant.Validate("Item No.", Item."No.");
ItemVariant.Code := CopyStr(Item."No." + 'V1', 1, MaxStrLen(ItemVariant.Code));
ItemVariant.Description := 'Test Variant';
ItemVariant.Insert(true);
end;

local procedure CreateShopifyProduct(var ShopifyProduct: Record "Shpfy Product"; ItemSystemId: Guid)
begin
ShopifyProduct.Init();
ShopifyProduct.Id := Random(2147483647);
ShopifyProduct."Item SystemId" := ItemSystemId;
ShopifyProduct."Shop Code" := Shop.Code;
ShopifyProduct."Has Variants" := true;
ShopifyProduct.Insert(false);
end;

local procedure CreateShopifyVariant(var ShopifyVariant: Record "Shpfy Variant"; ShopifyProduct: Record "Shpfy Product"; ItemSystemId: Guid; Option1Name: Text[50]; Option1Value: Text[255])
begin
ShopifyVariant.Init();
ShopifyVariant.Id := Random(2147483647);
ShopifyVariant."Product Id" := ShopifyProduct.Id;
ShopifyVariant."Item SystemId" := ItemSystemId;
ShopifyVariant."Shop Code" := Shop.Code;
ShopifyVariant."Option 1 Name" := Option1Name;
ShopifyVariant."Option 1 Value" := CopyStr(Option1Value, 1, MaxStrLen(ShopifyVariant."Option 1 Value"));
ShopifyVariant.Insert(false);
end;

local procedure CreateBlankShopifyVariant(var ShopifyVariant: Record "Shpfy Variant"; ShopifyProduct: Record "Shpfy Product"; ItemSystemId: Guid)
begin
ShopifyVariant.Init();
ShopifyVariant.Id := Random(2147483647);
ShopifyVariant."Product Id" := ShopifyProduct.Id;
ShopifyVariant."Item SystemId" := ItemSystemId;
ShopifyVariant."Shop Code" := Shop.Code;
ShopifyVariant."Option 1 Name" := '';
ShopifyVariant."Option 1 Value" := '';
ShopifyVariant.Insert(false);
end;
}
Loading