From f2f6a5754d86f52adeec52dc952afeb5f2bfceb6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 17 Jun 2026 14:06:29 +0100 Subject: [PATCH 01/12] WIP make `shopifyId` and `shopifyGid` consistent across the system --- CHANGELOG-WIP.md | 24 ++++ src/Plugin.php | 4 +- src/collections/VariantCollection.php | 1 + src/elements/Product.php | 2 +- src/elements/db/ProductQuery.php | 4 +- src/fieldlayoutelements/VariantsField.php | 2 +- src/gql/types/Variant.php | 2 - src/handlers/Webhook.php | 4 +- src/helpers/Product.php | 2 +- src/jobs/ProcessBulkOperationData.php | 12 +- src/migrations/Install.php | 17 ++- ..._shopifyId_to_shopifyGid_in_data_table.php | 56 +++++++++ ...to_shopifyGid_in_bulk_operations_table.php | 35 ++++++ src/models/BulkOperation.php | 8 +- src/models/Variant.php | 14 ++- src/records/BulkOperation.php | 2 +- src/records/Product.php | 2 +- src/records/ShopifyData.php | 1 + src/services/Api.php | 2 +- src/services/BulkOperations.php | 26 ++-- src/services/Products.php | 81 ++++++++---- tests/fixtures/BulkOperationsFixture.php | 2 +- tests/fixtures/ShopifyDataFixture.php | 2 +- .../fixtures/data/shopify-bulk-operations.php | 10 +- tests/fixtures/data/shopify-data.php | 118 +++++++++--------- .../jobs/ProcessBulkOperationDataTest.php | 22 ++-- tests/unit/services/BulkOperationsTest.php | 34 ++--- tests/unit/services/ProductsTest.php | 18 +-- 28 files changed, 343 insertions(+), 164 deletions(-) create mode 100644 CHANGELOG-WIP.md create mode 100644 src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php create mode 100644 src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000..0af83114 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,24 @@ +# WIP Release Notes for Shopify 8.0 + +### Extensibility + +- Added `craft\shopify\models\BulkOperation::$shopifyGid`. +- Added `craft\shopify\models\Variant::$shopifyGid`. +- Added `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyGid`. +- Added `craft\shopify\services\BulkOperations::getBulkOperationByShopifyGid()`. +- Added `craft\shopify\services\Products::deleteProductByShopifyGid()`. +- Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. +- Added `craft\shopify\services\Products::syncProductByShopifyGid()`. +- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- Deprecated `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId`. Use `$bulkOperationShopifyGid` instead. +- Deprecated `craft\shopify\models\BulkOperation::$shopifyId`. Use `$shopifyGid` instead. +- Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. +- Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead. +- Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead. +- Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead. + +### System + +- The `shopify_data` table's `shopifyId` column has been renamed to `shopifyGid`. A new generated `shopifyId` column (the numeric ID at the end of the GID) has been added. +- The `shopify_bulkoperations` table's `shopifyId` column has been renamed to `shopifyGid`. diff --git a/src/Plugin.php b/src/Plugin.php index a3830138..742c8930 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -450,8 +450,8 @@ private function _registerGarbageCollection(): void 'products.shopifyId', ]) ->from(Table::PRODUCTS . ' products') - ->leftJoin(Table::DATA . ' data', '[[data.shopifyId]] = [[products.shopifyGid]]') - ->where(['data.shopifyId' => null]) + ->leftJoin(Table::DATA . ' data', '[[data.shopifyGid]] = [[products.shopifyGid]]') + ->where(['data.shopifyGid' => null]) ->all(); $shopifyIds = ArrayHelper::getColumn($shopifyProductElementsMissingData, 'shopifyId'); diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php index a489f649..fd381c32 100644 --- a/src/collections/VariantCollection.php +++ b/src/collections/VariantCollection.php @@ -42,6 +42,7 @@ public static function make($items = []) $item = Craft::createObject([ 'class' => Variant::class, 'id' => $item->id, + 'shopifyGid' => $item->shopifyGid, 'shopifyId' => $item->shopifyId, 'type' => $item->type, 'parentId' => $item->parentId, diff --git a/src/elements/Product.php b/src/elements/Product.php index 6c155700..5916582e 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -823,7 +823,7 @@ public function afterDelete(): void { // Remove all the product shopify data if ($this->shopifyGid && $this->getIsCanonical()) { - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->shopifyGid); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->shopifyGid); } parent::afterDelete(); diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index 66fc57bb..8f295c6c 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -396,8 +396,8 @@ protected function beforePrepare(): bool // join standard product element table that only contains the shopifyId $this->joinElementTable('shopify_products'); - $this->query->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyId]] = [[shopify_products.shopifyGid]]')); - $this->subQuery->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyId]] = [[shopify_products.shopifyGid]]')); + $this->query->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyGid]] = [[shopify_products.shopifyGid]]')); + $this->subQuery->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyGid]] = [[shopify_products.shopifyGid]]')); $this->query->select([ 'shopify_products.shopifyId', diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php index cab9e0e1..72b7f284 100644 --- a/src/fieldlayoutelements/VariantsField.php +++ b/src/fieldlayoutelements/VariantsField.php @@ -63,7 +63,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa ]; foreach ($variants as $variant) { - $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant->shopifyId)); + $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), $variant->shopifyId); $title = $variant->title; $sku = $variant->sku; diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php index b80276cc..96579ceb 100644 --- a/src/gql/types/Variant.php +++ b/src/gql/types/Variant.php @@ -56,13 +56,11 @@ public static function getFieldDefinitions(): array 'name' => 'shopifyId', 'type' => Type::string(), 'description' => 'Shopify ID of the variant.', - 'resolve' => fn(VariantElement $source) => str_replace('gid://shopify/ProductVariant/', '', $source->shopifyId), ], 'shopifyGid' => [ 'name' => 'shopifyGid', 'type' => Type::string(), 'description' => 'Shopify GID of the variant.', - 'resolve' => fn(VariantElement $source) => $source->shopifyId, ], 'title' => [ 'name' => 'title', diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php index 1b31c923..700944d8 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -24,10 +24,10 @@ public function handle(string $topic, string $shop, array $body): void switch ($topic) { case Topics::PRODUCTS_UPDATE: case Topics::PRODUCTS_CREATE: - Plugin::getInstance()->getProducts()->syncProductByShopifyId($body['id']); + Plugin::getInstance()->getProducts()->syncProductByShopifyGid($body['id']); break; case Topics::PRODUCTS_DELETE: - Plugin::getInstance()->getProducts()->deleteProductByShopifyId($body['id']); + Plugin::getInstance()->getProducts()->deleteProductByShopifyGid($body['id']); break; case Topics::INVENTORY_ITEMS_UPDATE: Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']); diff --git a/src/helpers/Product.php b/src/helpers/Product.php index abef6ce9..0d53946f 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -131,7 +131,7 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet // This is the date updated in the database which represents the last time it was updated from a Shopify webhook or sync. /** @var ShopifyData $productData */ - $productData = ShopifyData::find()->where(['shopifyId' => $product->shopifyGid])->one(); + $productData = ShopifyData::find()->where(['shopifyGid' => $product->shopifyGid])->one(); $dateUpdated = DateTimeHelper::toDateTime($productData->dateUpdated); $now = new \DateTime(); $diff = $now->diff($dateUpdated); diff --git a/src/jobs/ProcessBulkOperationData.php b/src/jobs/ProcessBulkOperationData.php index 0799c122..23655b9a 100644 --- a/src/jobs/ProcessBulkOperationData.php +++ b/src/jobs/ProcessBulkOperationData.php @@ -23,7 +23,7 @@ class ProcessBulkOperationData extends BaseBatchedJob /** * @var string */ - public string $bulkOperationShopifyId; + public string $bulkOperationShopifyGid; /** * @var string @@ -110,7 +110,7 @@ protected function processItem(mixed $item): void // Find data records based on their Shopify ID and parent ID. This is to avoid overwriting // records with the same ID but different parents (e.g. Metafields, Images etc). - $record = ShopifyData::findOne(['shopifyId' => $item['id'], 'parentId' => $parentId]); + $record = ShopifyData::findOne(['shopifyGid' => $item['id'], 'parentId' => $parentId]); if (!$record) { $record = new ShopifyData(); } @@ -119,7 +119,7 @@ protected function processItem(mixed $item): void $parts = explode('/', str_replace('gid://shopify/', '', $item['id'])); $type = $parts[0]; - $record->shopifyId = $item['id']; + $record->shopifyGid = $item['id']; $record->type = $type; $record->data = $item; $record->parentId = $item['__parentId'] ?? null; @@ -139,7 +139,7 @@ protected function before(): void parent::before(); // Make sure bulk op is marked as processing - $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId); + $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid); if (!$bulkOperation) { return; @@ -153,7 +153,7 @@ protected function before(): void if ($this->clearData === BulkOperationRecord::CLEAR_DATA_ALL) { ShopifyData::deleteAll(); } elseif ($this->clearData !== BulkOperationRecord::CLEAR_DATA_NONE) { - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->clearData); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->clearData); } } @@ -165,7 +165,7 @@ protected function after(): void parent::after(); // Mark bulk op as completed - $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId); + $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid); if (!$bulkOperation) { return; diff --git a/src/migrations/Install.php b/src/migrations/Install.php index fffe0c93..66ccf78b 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -57,7 +57,7 @@ public function createTables(): void $this->archiveTableIfExists(Table::DATA); $this->createTable(Table::DATA, [ 'id' => $this->primaryKey(), - 'shopifyId' => $this->string(), + 'shopifyGid' => $this->string(), 'type' => $this->string(), 'data' => $this->json(), 'parentId' => $this->string(), @@ -69,7 +69,7 @@ public function createTables(): void $this->archiveTableIfExists(Table::BULK_OPERATIONS); $this->createTable(Table::BULK_OPERATIONS, [ 'id' => $this->primaryKey(), - 'shopifyId' => $this->string(), + 'shopifyGid' => $this->string(), 'url' => $this->text(), 'objectCount' => $this->integer(), 'query' => $this->text(), @@ -139,6 +139,17 @@ public function createGeneratedColumns(): void $db->quoteColumnName($alias) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" . $expression . ") STORED;"); } + + // Derived shopifyId — the numeric ID at the end of the GID (e.g. "7136060145715" from "gid://shopify/Product/7136060145715") + if ($db->getIsPgsql()) { + $shopifyIdExpression = "regexp_replace(\"shopifyGid\", '^.*/', '')"; + } else { + $shopifyIdExpression = "SUBSTRING_INDEX(`shopifyGid`, '/', -1)"; + } + + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . + $db->quoteColumnName('shopifyId') . ' ' . $qb->getColumnType($this->string()) . " GENERATED ALWAYS AS (" . + $shopifyIdExpression . ") STORED;"); } /** @@ -148,7 +159,7 @@ public function createIndexes(): void { $this->createIndex(null, Table::PRODUCTS, ['shopifyId'], false); $this->createIndex(null, Table::PRODUCTS, ['shopifyGid'], false); - $this->createIndex(null, Table::DATA, ['shopifyId'], false); + $this->createIndex(null, Table::DATA, ['shopifyGid'], false); $this->createIndex(null, Table::DATA, ['parentId'], false); } diff --git a/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php b/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php new file mode 100644 index 00000000..3ba15894 --- /dev/null +++ b/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php @@ -0,0 +1,56 @@ +db->columnExists(Table::DATA, 'shopifyGid')) { + return true; + } + + $db = $this->getDb(); + $qb = $db->getQueryBuilder(); + + // Drop the existing index on shopifyId before renaming + $this->dropIndexIfExists(Table::DATA, ['shopifyId'], false); + + // Rename shopifyId → shopifyGid + $this->renameColumn(Table::DATA, 'shopifyId', 'shopifyGid'); + + // Recreate the index on the renamed column + $this->createIndex(null, Table::DATA, ['shopifyGid'], false); + + // Add generated shopifyId column — the numeric ID at the end of the GID + if ($db->getIsPgsql()) { + $expression = "regexp_replace(\"shopifyGid\", '^.*/', '')"; + } else { + $expression = "SUBSTRING_INDEX(`shopifyGid`, '/', -1)"; + } + + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . + $db->quoteColumnName('shopifyId') . ' ' . $qb->getColumnType($this->string()) . " GENERATED ALWAYS AS (" . + $expression . ") STORED;"); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table cannot be reverted.\n"; + return false; + } +} diff --git a/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php b/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php new file mode 100644 index 00000000..72b243f5 --- /dev/null +++ b/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php @@ -0,0 +1,35 @@ +db->columnExists(Table::BULK_OPERATIONS, 'shopifyGid')) { + return true; + } + + $this->renameColumn(Table::BULK_OPERATIONS, 'shopifyId', 'shopifyGid'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/BulkOperation.php b/src/models/BulkOperation.php index 0e41d947..3bf4c1ca 100644 --- a/src/models/BulkOperation.php +++ b/src/models/BulkOperation.php @@ -26,9 +26,9 @@ class BulkOperation extends Model public ?int $id = null; /** - * @var string|null The Shopify ID of the bulk operation. + * @var string|null The Shopify GID of the bulk operation (e.g. "gid://shopify/BulkOperation/123456789"). */ - public ?string $shopifyId = null; + public ?string $shopifyGid = null; /** * @var string|null The URL of the bulk operation data. @@ -80,8 +80,8 @@ protected function defineRules(): array $rules = parent::defineRules(); $rules[] = [['id', 'objectCount'], 'number', 'integerOnly' => true]; - $rules[] = [['shopifyId', 'url'], 'string']; - $rules[] = [['id', 'shopifyId', 'url', 'objectCount', 'status', 'shopifyStatus', 'query', 'dateCreated', 'dateUpdated'], 'safe']; + $rules[] = [['shopifyGid', 'url'], 'string']; + $rules[] = [['id', 'shopifyGid', 'url', 'objectCount', 'status', 'shopifyStatus', 'query', 'dateCreated', 'dateUpdated'], 'safe']; return $rules; } diff --git a/src/models/Variant.php b/src/models/Variant.php index 688e4990..991ca967 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -16,6 +16,7 @@ /** * Variant model. * + * @property-read string $shopifyGid * @property-read string $shopifyId * @property-read string $title * @property-read string $sku @@ -31,7 +32,12 @@ class Variant extends Model public ?int $id = null; /** - * @var string|null The Shopify ID of the variant. + * @var string|null The Shopify GID of the variant (e.g. "gid://shopify/ProductVariant/123456789"). + */ + public ?string $shopifyGid = null; + + /** + * @var string|null The numeric Shopify ID of the variant (last segment of the GID). */ public ?string $shopifyId = null; @@ -103,7 +109,7 @@ protected function defineRules(): array { $rules = parent::defineRules(); - $rules[] = [['id', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe']; + $rules[] = [['id', 'shopifyGid', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe']; return $rules; } @@ -165,7 +171,7 @@ public function setMetafields(string|array $value): void */ public function getMetafields(): array { - if (!$this->shopifyId) { + if (!$this->shopifyGid) { return []; } @@ -173,7 +179,7 @@ public function getMetafields(): array return $this->_metaFields; } - $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyId); + $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); $metafields = $data ->mapWithKeys(function($d) { diff --git a/src/records/BulkOperation.php b/src/records/BulkOperation.php index 258649c8..0061e04c 100644 --- a/src/records/BulkOperation.php +++ b/src/records/BulkOperation.php @@ -17,7 +17,7 @@ * @since 6.0.0 * * @property int $id - * @property string $shopifyId + * @property string $shopifyGid * @property string $url * @property string $status * @property string $shopifyStatus diff --git a/src/records/Product.php b/src/records/Product.php index 36459106..46cc566a 100644 --- a/src/records/Product.php +++ b/src/records/Product.php @@ -39,6 +39,6 @@ public function getElement(): ActiveQueryInterface public function getData(): ActiveQueryInterface { - return $this->hasOne(ShopifyData::class, ['shopifyGid' => 'shopifyId']); + return $this->hasOne(ShopifyData::class, ['shopifyGid' => 'shopifyGid']); } } diff --git a/src/records/ShopifyData.php b/src/records/ShopifyData.php index 59625b0f..75da9387 100644 --- a/src/records/ShopifyData.php +++ b/src/records/ShopifyData.php @@ -17,6 +17,7 @@ * @since 6.0.0 * * @property int $id + * @property string $shopifyGid * @property string $shopifyId * @property string $type * @property string|array $data diff --git a/src/services/Api.php b/src/services/Api.php index 89f4b415..ec7cb293 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -176,7 +176,7 @@ public function getShop(bool $update = false): ?array $shopRecord = new ShopifyData(); } - $shopRecord->shopifyId = $response['id']; + $shopRecord->shopifyGid = $response['id']; $shopRecord->type = 'Shop'; $shopRecord->data = $response; diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index 8e6cb586..cdb9e1db 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -66,14 +66,26 @@ public function getAllBulkOperations(): Collection return collect($bulkOps); } + /** + * @param string $gid + * @return BulkOperation|null + * @throws InvalidConfigException + * @since 8.0.0 + */ + public function getBulkOperationByShopifyGid(string $gid): ?BulkOperation + { + return $this->getAllBulkOperations()->firstWhere('shopifyGid', $gid); + } + /** * @param string $shopifyId * @return BulkOperation|null * @throws InvalidConfigException + * @deprecated in 8.0.0. Use [[getBulkOperationByShopifyGid()]] instead. */ public function getBulkOperationByShopifyId(string $shopifyId): ?BulkOperation { - return $this->getAllBulkOperations()->firstWhere('shopifyId', $shopifyId); + return $this->getBulkOperationByShopifyGid($shopifyId); } /** @@ -211,7 +223,7 @@ public function nextBulkOperation(): bool } $bulkOperation->shopifyStatus = $op['status']; - $bulkOperation->shopifyId = $op['id']; + $bulkOperation->shopifyGid = $op['id']; $this->saveBulkOperation($bulkOperation); return true; @@ -237,7 +249,7 @@ public function handleBulkOperationFinished(array $payload): void } // Load our local record of the bulk op: - $bulkOperation = $this->getBulkOperationByShopifyId($payload['admin_graphql_api_id']); + $bulkOperation = $this->getBulkOperationByShopifyGid($payload['admin_graphql_api_id']); if (!$bulkOperation) { // Ok... maybe it was initiated for a different environment? @@ -321,7 +333,7 @@ public function queueNextBulkOperation(): bool /** @var BulkOperation $bulkOperation */ $bulkOperation = Craft::createObject(array_merge($nextToProcess, ['class' => BulkOperation::class])); if (!Queue::push(new ProcessBulkOperationData([ - 'bulkOperationShopifyId' => $bulkOperation->shopifyId, + 'bulkOperationShopifyGid' => $bulkOperation->shopifyGid, 'dataUrl' => $bulkOperation->url, 'objectCount' => $bulkOperation->objectCount, 'clearData' => $bulkOperation->clearData, @@ -351,7 +363,7 @@ public function saveBulkOperation(BulkOperation $bulkOperation, bool $runValidat if ($bulkOperation->id) { $record = BulkOperationRecord::findOne($bulkOperation->id); } else { - $record = BulkOperationRecord::findOne(['shopifyId' => $bulkOperation->shopifyId]); + $record = BulkOperationRecord::findOne(['shopifyGid' => $bulkOperation->shopifyGid]); } if (!$record) { @@ -365,7 +377,7 @@ public function saveBulkOperation(BulkOperation $bulkOperation, bool $runValidat } $record->clearData = $bulkOperation->clearData; - $record->shopifyId = $bulkOperation->shopifyId; + $record->shopifyGid = $bulkOperation->shopifyGid; $record->url = $bulkOperation->url; $record->objectCount = $bulkOperation->objectCount; $record->query = $bulkOperation->query; @@ -445,7 +457,7 @@ private function _createBulkOperationQuery(): Query 'id', 'objectCount', 'query', - 'shopifyId', + 'shopifyGid', 'status', 'shopifyStatus', 'url', diff --git a/src/services/Products.php b/src/services/Products.php index 52483321..9ad9af73 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -58,16 +58,29 @@ class Products extends Component */ public const EVENT_BEFORE_SYNCHRONIZE_PRODUCT = 'beforeSynchronizeProduct'; + /** + * @param string $gid + * @return void + * @throws InvalidConfigException + * @throws \yii\db\Exception + * @since 8.0.0 + */ + public function syncProductByShopifyGid(string $gid): void + { + $gid = $this->normalizeShopifyGid($gid); + Plugin::getInstance()->getBulkOperations()->createBulkOperation((string)Plugin::getInstance()->getApi()->getProductGql($gid), $gid); + } + /** * @param string $id * @return void * @throws InvalidConfigException * @throws \yii\db\Exception + * @deprecated in 8.0.0. Use [[syncProductByShopifyGid()]] instead. */ public function syncProductByShopifyId(string $id): void { - $shopifyId = $this->normalizeShopifyGid($id); - Plugin::getInstance()->getBulkOperations()->createBulkOperation((string)Plugin::getInstance()->getApi()->getProductGql($id), $shopifyId); + $this->syncProductByShopifyGid($id); } /** @@ -100,7 +113,7 @@ public function syncProductByInventoryItemId($id): void $productId = $item['variant']['product']['id']; - $this->syncProductByShopifyId($productId); + $this->syncProductByShopifyGid($productId); } /** @@ -178,52 +191,62 @@ public function normalizeShopifyGid(string $shopifyId, string $type = 'Product') } /** - * Deletes a product element by the Shopify ID. + * Deletes a product element by the Shopify GID. * - * @param $id + * @param string $gid * @return void * @throws \Throwable * @throws StaleObjectException + * @since 8.0.0 */ - public function deleteProductByShopifyId($id): void + public function deleteProductByShopifyGid(string $gid): void { - if ($id) { - if ($product = Product::find()->shopifyId($id)->one()) { + if ($gid) { + if ($product = Product::find()->shopifyId($gid)->one()) { // We hard delete because it will have been hard deleted in Shopify Craft::$app->getElements()->deleteElement($product, true); } - // Delete data in shopify data table - // Delete the product data - $shopifyId = $this->normalizeShopifyGid($id); - $this->deleteShopifyDataByShopifyId($shopifyId); + $this->deleteShopifyDataByShopifyGid($this->normalizeShopifyGid($gid)); } } /** - * @param string $shopifyId + * @param $id + * @return void + * @throws \Throwable + * @throws StaleObjectException + * @deprecated in 8.0.0. Use [[deleteProductByShopifyGid()]] instead. + */ + public function deleteProductByShopifyId($id): void + { + $this->deleteProductByShopifyGid($id); + } + + /** + * @param string $gid * @return void * @throws StaleObjectException * @throws \Throwable - * @since 6.0.0 + * @since 8.0.0 */ - public function deleteShopifyDataByShopifyId(string $shopifyId): void + public function deleteShopifyDataByShopifyGid(string $gid): void { - // Support both id and gid - $shopifyId = $this->normalizeShopifyGid($shopifyId); + // Support both numeric ID and GID + $gid = $this->normalizeShopifyGid($gid); /** @var ShopifyData|null $shopifyData */ - $shopifyData = ShopifyData::find()->where(['shopifyId' => $shopifyId])->one(); + $shopifyData = ShopifyData::find()->where(['shopifyGid' => $gid])->one(); // Delete if possible $shopifyData?->delete(); // Delete any child data that may still exist /** @var ShopifyData[] $shopifyData */ - $shopifyData = ShopifyData::find()->where(['parentId' => $shopifyId])->all(); + $shopifyData = ShopifyData::find()->where(['parentId' => $gid])->all(); $childIds = []; foreach ($shopifyData as $data) { - $childIds[] = $data->shopifyId; + $childIds[] = $data->shopifyGid; $data->delete(); } @@ -233,12 +256,24 @@ public function deleteShopifyDataByShopifyId(string $shopifyId): void /** @var ShopifyData[] $shopifyData */ $shopifyData = ShopifyData::find()->where(['parentId' => $childId])->all(); foreach ($shopifyData as $data) { - $childIds[] = $data->shopifyId; + $childIds[] = $data->shopifyGid; $data->delete(); } } } + /** + * @param string $shopifyId + * @return void + * @throws StaleObjectException + * @throws \Throwable + * @deprecated in 8.0.0. Use [[deleteShopifyDataByShopifyGid()]] instead. + */ + public function deleteShopifyDataByShopifyId(string $shopifyId): void + { + $this->deleteShopifyDataByShopifyGid($shopifyId); + } + /** * @param array|Product[] $products * @return array @@ -290,7 +325,7 @@ public function eagerLoadVariantsForProducts(array $products): array $variantsByProductId = []; $return = $this->_eagerLoadTypeOnProducts($products, 'ProductVariant', function($product, $rows) use (&$variantsByProductId, &$variantIds) { foreach ($rows as $row) { - $variantIds[] = $row->shopifyId; + $variantIds[] = $row->shopifyGid; } $variantsByProductId[$product->shopifyGid] = $rows; @@ -310,7 +345,7 @@ public function eagerLoadVariantsForProducts(array $products): array if ($metafieldsData->isNotEmpty()) { $variants->map(function(Variant$variant) use ($metafieldsData) { - $metafields = $metafieldsData->get($variant->shopifyId); + $metafields = $metafieldsData->get($variant->shopifyGid); if (!empty($metafields)) { $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) { $data = Json::decodeIfJson($d->data); diff --git a/tests/fixtures/BulkOperationsFixture.php b/tests/fixtures/BulkOperationsFixture.php index 1b4fd2f9..486b52ac 100644 --- a/tests/fixtures/BulkOperationsFixture.php +++ b/tests/fixtures/BulkOperationsFixture.php @@ -26,7 +26,7 @@ public function load(): void foreach ($rows as $key => $row) { $model = new BulkOperation(); - $model->shopifyId = $row['shopifyId']; + $model->shopifyGid = $row['shopifyGid']; $model->url = $row['url']; $model->objectCount = (int)($row['objectCount'] ?? 0); $model->query = $row['query'] ?? null; diff --git a/tests/fixtures/ShopifyDataFixture.php b/tests/fixtures/ShopifyDataFixture.php index 62144fbe..db8892cb 100644 --- a/tests/fixtures/ShopifyDataFixture.php +++ b/tests/fixtures/ShopifyDataFixture.php @@ -29,7 +29,7 @@ public function load(): void foreach ($rows as $row) { $uid = $row['uid']; \Yii::$app->db->createCommand()->insert(Table::DATA, [ - 'shopifyId' => $row['shopifyId'], + 'shopifyGid' => $row['shopifyGid'], 'type' => $row['type'], 'data' => is_array($row['data']) ? json_encode($row['data']) : $row['data'], 'parentId' => $row['parentId'], diff --git a/tests/fixtures/data/shopify-bulk-operations.php b/tests/fixtures/data/shopify-bulk-operations.php index d3774db8..4725c0d8 100644 --- a/tests/fixtures/data/shopify-bulk-operations.php +++ b/tests/fixtures/data/shopify-bulk-operations.php @@ -3,7 +3,7 @@ return [ 'op_completed_74' => [ 'id' => 74, - 'shopifyId' => 'gid://shopify/BulkOperation/4848685842483', + 'shopifyGid' => 'gid://shopify/BulkOperation/4848685842483', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/45bd3bca5ff2b0445c4ee86c7b6a?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781775894&Signature=gYorZwhi9arbpjvRo5p%2BqkRn9NYhDNwmi6ZJ%2FHSH3BgiQqu62pByOOUpZ%2BG3Qe4QI%2Frc%2F8XJYoSudrjrRBtnqn%2Fess73avZYDa7%2BmG4qPyJ%2BToJu04H5xYo%2FiIbSj6mJsHJJ1td2w%2B0O3hbbcPmVFaEZkR8vZpfa0MkC8aPO4VFbj%2FfnJdTDr5kmLV5E6PdlFy8vfKCBsXoj4Hh0dDImi9IFSEjEx80hc7%2FXtxBe374vam%2BM%2BGY0KCFglfNmnWyPjGJtnjXciRprOkCJ%2Fcz5vlNbYiR%2FAdRKF6i51HeztiBmD8vCrstiktNug9wruwYcawmGqQiXa4oJMLAWKOe4lg%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4848685842483.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4848685842483.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 60, 'query' => 'query { products(query: "id:6656149192755") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor } } } }', @@ -16,7 +16,7 @@ ], 'op_completed_73' => [ 'id' => 73, - 'shopifyId' => 'gid://shopify/BulkOperation/4846181023795', + 'shopifyGid' => 'gid://shopify/BulkOperation/4846181023795', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/0b38d4b425e19b4600ddc7d6c366?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781705438&Signature=PMTzLXL%2B7Nb%2B5RCPXypekUy8BmX1AZTfqIxT0TkwKe7%2FJ4T5HRCuZySB4gZN5wCq%2Fe39DKtGFKuJwscJOoWp4tSKGCnPNtZECvzNnZKo8rDZurffaTFOWsHIgJ2LqlMYF%2BPm675YY0Lh%2FfWDcp5LgWBGCgxRHwGa2YcAyRxE8jJBFC4b8wXmJEQ8bnEKOB5AQpeVK5p%2B7PF5kSXSMp%2F83KAUETJUnY2KtlczABAo8JcyoHBVj7n6pZhVHal%2Bz1HYdC8rZZzO%2FsA3iJ2mX7z%2FrUFaZAoGIrazPJXninpLyA6wz%2F4y41HDV390ekMj6va4tu%2BIA1VuZo8MN4FHnelnSQ%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846181023795.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846181023795.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 3, 'query' => 'query { products(query: "id:7136093208627") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', @@ -29,7 +29,7 @@ ], 'op_completed_72' => [ 'id' => 72, - 'shopifyId' => 'gid://shopify/BulkOperation/4846126170163', + 'shopifyGid' => 'gid://shopify/BulkOperation/4846126170163', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/1261ad672da99ee3b988a510cef2?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781703757&Signature=anJScMX3XZ4HGBwJbEnGsyQElHcJG%2BplwGMocdYEL%2FTJoiF13cSQAV6CKKiQ7hfzlrkuHt1QQAZl68TX38SJLtvVBXE64hA2C7eLAb9ncpJvBCcXo54TjCGkyns54dBYjsmNtOXdHBuIbAsjbFQ52Elw3QZQV57yjITmqEgDkHdqpgb011C0bxX4lpwMW4e%2FpV0JgITFeYJrfRNcFEgu6RzsWQvuj8114VSvqgHGN2k50AVyUtvY9b39wsvZTNOP9%2FNqdary%2BO0RPY7NqmQUF%2BHD6cBm%2F2EF%2F%2B0RsfpdUJ3loWUtXAuOH9kIZP2DZFnoxoAYu6QGjSNLP01V%2F3hvSA%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846126170163.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846126170163.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 3, 'query' => 'query { products(query: "id:7136068075571") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', @@ -42,7 +42,7 @@ ], 'op_completed_71' => [ 'id' => 71, - 'shopifyId' => 'gid://shopify/BulkOperation/4846010433587', + 'shopifyGid' => 'gid://shopify/BulkOperation/4846010433587', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/6a97ef72dca29699d67ba70d3e8b?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781700339&Signature=mfZ76fGjrnk9G04sllkiktezTLFsLoikUhqftMHP4CNt5fVgaGIQY1%2Buvh2lVKLXGlIH%2F44WXiAZD%2F0gK28gRnT0kBLcENPR3ZmoAmpFHDGbB4MKEv6bUkrJv7wlxZD0ZilyYgnVLEyNuV3CvLgqe9HRz3S8bfeIH3jfxt5qxCzfcdtNIoRg0qmkageYF6drLCdCQ7y7UPvCU9e0De9HEuiaycfFty6Gyc2%2BO1pFCxbQu0I6hZJbSbney3QJvdatNo8j05zWFFalPI9iPXUZnHxGm6Sc4ISP2FZCcutcJ9r617%2F5uSYSsHi%2F%2F5e0Deb%2FHAEQJYiZJk8YXHL7tvNqHQ%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846010433587.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846010433587.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 3, 'query' => 'query { products(query: "id:7136099729459") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', @@ -55,7 +55,7 @@ ], 'op_completed_70' => [ 'id' => 70, - 'shopifyId' => 'gid://shopify/BulkOperation/4845996933171', + 'shopifyGid' => 'gid://shopify/BulkOperation/4845996933171', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/45e4ebfb3778f9f27c5709136189?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781700017&Signature=XOm1n1r681pq%2B7iQpcLHD%2FMmYOqv8Fa33LOcE8LSdYLezdRDBR6wiYZ2jCNvtA5nOGBzUe3XDadhTbaxYVqlIaeZDx1HBB14wPfHjbLmNU5hRXa0094tQiw9IU23iCi4lM%2Bc2PX5SrC5WJbFwcDrl%2BiOzrazNpjgp5rVnW4QWPepNaN7TBeiHMHBqxNnY7FxCEi2FQxK6cchM1Rjuro0QmtNufRxiuw9vhxbjf8b6VOtZx4agdnNms3Rhjioq7191qfdeLvzaz2aepXoJd%2BhAVAmJo5wgMkgRF2wddgCtrYe6SHFdxP%2BCrlV1XHpbOQiI1G52%2BQorUps3EOlX8rHfg%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4845996933171.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4845996933171.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 1880, 'query' => 'query { products { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', diff --git a/tests/fixtures/data/shopify-data.php b/tests/fixtures/data/shopify-data.php index 183d5813..7b1aca5d 100644 --- a/tests/fixtures/data/shopify-data.php +++ b/tests/fixtures/data/shopify-data.php @@ -3,7 +3,7 @@ return [ 'Product_product_7136060145715' => [ 'id' => 64533, - 'shopifyId' => 'gid://shopify/Product/7136060145715', + 'shopifyGid' => 'gid://shopify/Product/7136060145715', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136060145715', @@ -126,7 +126,7 @@ ], 'Product_product_7136060964915' => [ 'id' => 64581, - 'shopifyId' => 'gid://shopify/Product/7136060964915', + 'shopifyGid' => 'gid://shopify/Product/7136060964915', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136060964915', @@ -262,7 +262,7 @@ ], 'Product_product_7136062865459' => [ 'id' => 64674, - 'shopifyId' => 'gid://shopify/Product/7136062865459', + 'shopifyGid' => 'gid://shopify/Product/7136062865459', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136062865459', @@ -336,7 +336,7 @@ ], 'Product_product_7136074399795' => [ 'id' => 65044, - 'shopifyId' => 'gid://shopify/Product/7136074399795', + 'shopifyGid' => 'gid://shopify/Product/7136074399795', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136074399795', @@ -396,7 +396,7 @@ ], 'Product_product_7136075251763' => [ 'id' => 65080, - 'shopifyId' => 'gid://shopify/Product/7136075251763', + 'shopifyGid' => 'gid://shopify/Product/7136075251763', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136075251763', @@ -456,7 +456,7 @@ ], 'Product_product_7136076070963' => [ 'id' => 65107, - 'shopifyId' => 'gid://shopify/Product/7136076070963', + 'shopifyGid' => 'gid://shopify/Product/7136076070963', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136076070963', @@ -515,7 +515,7 @@ ], 'Product_product_7136089669683' => [ 'id' => 65620, - 'shopifyId' => 'gid://shopify/Product/7136089669683', + 'shopifyGid' => 'gid://shopify/Product/7136089669683', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136089669683', @@ -574,7 +574,7 @@ ], 'Product_product_7136090816563' => [ 'id' => 65644, - 'shopifyId' => 'gid://shopify/Product/7136090816563', + 'shopifyGid' => 'gid://shopify/Product/7136090816563', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136090816563', @@ -633,7 +633,7 @@ ], 'Product_product_7136093863987' => [ 'id' => 65773, - 'shopifyId' => 'gid://shopify/Product/7136093863987', + 'shopifyGid' => 'gid://shopify/Product/7136093863987', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136093863987', @@ -688,7 +688,7 @@ ], 'Product_product_7136099500083' => [ 'id' => 66025, - 'shopifyId' => 'gid://shopify/Product/7136099500083', + 'shopifyGid' => 'gid://shopify/Product/7136099500083', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136099500083', @@ -749,7 +749,7 @@ ], 'MediaImage_mediaimage_23117943373875' => [ 'id' => 64534, - 'shopifyId' => 'gid://shopify/MediaImage/23117943373875', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943373875', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943373875', @@ -772,7 +772,7 @@ ], 'MediaImage_mediaimage_23117943406643' => [ 'id' => 64535, - 'shopifyId' => 'gid://shopify/MediaImage/23117943406643', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943406643', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943406643', @@ -795,7 +795,7 @@ ], 'MediaImage_mediaimage_23117943439411' => [ 'id' => 64536, - 'shopifyId' => 'gid://shopify/MediaImage/23117943439411', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943439411', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943439411', @@ -818,7 +818,7 @@ ], 'MediaImage_mediaimage_23117943472179' => [ 'id' => 64537, - 'shopifyId' => 'gid://shopify/MediaImage/23117943472179', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943472179', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943472179', @@ -841,7 +841,7 @@ ], 'MediaImage_mediaimage_23117943504947' => [ 'id' => 64538, - 'shopifyId' => 'gid://shopify/MediaImage/23117943504947', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943504947', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943504947', @@ -864,7 +864,7 @@ ], 'ProductVariant_productvariant_41966390083635' => [ 'id' => 64539, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390083635', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390083635', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390083635', @@ -919,7 +919,7 @@ ], 'ProductVariant_productvariant_41966390116403' => [ 'id' => 64540, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390116403', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390116403', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390116403', @@ -974,7 +974,7 @@ ], 'ProductVariant_productvariant_41966390149171' => [ 'id' => 64541, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390149171', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390149171', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390149171', @@ -1029,7 +1029,7 @@ ], 'ProductVariant_productvariant_41966390181939' => [ 'id' => 64542, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390181939', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390181939', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390181939', @@ -1084,7 +1084,7 @@ ], 'ProductVariant_productvariant_41966390214707' => [ 'id' => 64543, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390214707', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390214707', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390214707', @@ -1139,7 +1139,7 @@ ], 'ProductVariant_productvariant_41966390247475' => [ 'id' => 64544, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390247475', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390247475', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390247475', @@ -1194,7 +1194,7 @@ ], 'ProductVariant_productvariant_41966390280243' => [ 'id' => 64545, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390280243', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390280243', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390280243', @@ -1249,7 +1249,7 @@ ], 'ProductVariant_productvariant_41966390313011' => [ 'id' => 64546, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390313011', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390313011', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390313011', @@ -1304,7 +1304,7 @@ ], 'ProductVariant_productvariant_41966390345779' => [ 'id' => 64547, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390345779', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390345779', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390345779', @@ -1359,7 +1359,7 @@ ], 'MediaImage_mediaimage_23117946912819' => [ 'id' => 64582, - 'shopifyId' => 'gid://shopify/MediaImage/23117946912819', + 'shopifyGid' => 'gid://shopify/MediaImage/23117946912819', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117946912819', @@ -1382,7 +1382,7 @@ ], 'MediaImage_mediaimage_23117946945587' => [ 'id' => 64583, - 'shopifyId' => 'gid://shopify/MediaImage/23117946945587', + 'shopifyGid' => 'gid://shopify/MediaImage/23117946945587', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117946945587', @@ -1405,7 +1405,7 @@ ], 'MediaImage_mediaimage_23117946978355' => [ 'id' => 64584, - 'shopifyId' => 'gid://shopify/MediaImage/23117946978355', + 'shopifyGid' => 'gid://shopify/MediaImage/23117946978355', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117946978355', @@ -1428,7 +1428,7 @@ ], 'MediaImage_mediaimage_23117947011123' => [ 'id' => 64585, - 'shopifyId' => 'gid://shopify/MediaImage/23117947011123', + 'shopifyGid' => 'gid://shopify/MediaImage/23117947011123', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117947011123', @@ -1451,7 +1451,7 @@ ], 'MediaImage_mediaimage_23117947043891' => [ 'id' => 64586, - 'shopifyId' => 'gid://shopify/MediaImage/23117947043891', + 'shopifyGid' => 'gid://shopify/MediaImage/23117947043891', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117947043891', @@ -1474,7 +1474,7 @@ ], 'ProductVariant_productvariant_41966393098291' => [ 'id' => 64587, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393098291', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393098291', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393098291', @@ -1529,7 +1529,7 @@ ], 'ProductVariant_productvariant_41966393131059' => [ 'id' => 64588, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393131059', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393131059', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393131059', @@ -1584,7 +1584,7 @@ ], 'ProductVariant_productvariant_41966393163827' => [ 'id' => 64589, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393163827', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393163827', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393163827', @@ -1639,7 +1639,7 @@ ], 'ProductVariant_productvariant_41966393196595' => [ 'id' => 64590, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393196595', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393196595', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393196595', @@ -1694,7 +1694,7 @@ ], 'ProductVariant_productvariant_41966393229363' => [ 'id' => 64591, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393229363', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393229363', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393229363', @@ -1749,7 +1749,7 @@ ], 'ProductVariant_productvariant_41966393262131' => [ 'id' => 64592, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393262131', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393262131', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393262131', @@ -1804,7 +1804,7 @@ ], 'ProductVariant_productvariant_41966393294899' => [ 'id' => 64593, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393294899', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393294899', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393294899', @@ -1859,7 +1859,7 @@ ], 'ProductVariant_productvariant_41966393327667' => [ 'id' => 64594, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393327667', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393327667', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393327667', @@ -1914,7 +1914,7 @@ ], 'ProductVariant_productvariant_41966393360435' => [ 'id' => 64595, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393360435', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393360435', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393360435', @@ -1969,7 +1969,7 @@ ], 'ProductVariant_productvariant_41966393393203' => [ 'id' => 64596, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393393203', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393393203', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393393203', @@ -2024,7 +2024,7 @@ ], 'ProductVariant_productvariant_41966393425971' => [ 'id' => 64597, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393425971', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393425971', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393425971', @@ -2079,7 +2079,7 @@ ], 'MediaImage_mediaimage_23117955760179' => [ 'id' => 64675, - 'shopifyId' => 'gid://shopify/MediaImage/23117955760179', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955760179', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955760179', @@ -2102,7 +2102,7 @@ ], 'MediaImage_mediaimage_23117955825715' => [ 'id' => 64676, - 'shopifyId' => 'gid://shopify/MediaImage/23117955825715', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955825715', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955825715', @@ -2125,7 +2125,7 @@ ], 'MediaImage_mediaimage_23117955858483' => [ 'id' => 64677, - 'shopifyId' => 'gid://shopify/MediaImage/23117955858483', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955858483', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955858483', @@ -2148,7 +2148,7 @@ ], 'MediaImage_mediaimage_23117955891251' => [ 'id' => 64678, - 'shopifyId' => 'gid://shopify/MediaImage/23117955891251', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955891251', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955891251', @@ -2171,7 +2171,7 @@ ], 'ProductVariant_productvariant_41966400143411' => [ 'id' => 64679, - 'shopifyId' => 'gid://shopify/ProductVariant/41966400143411', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966400143411', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966400143411', @@ -2226,7 +2226,7 @@ ], 'MediaImage_mediaimage_23118015758387' => [ 'id' => 65045, - 'shopifyId' => 'gid://shopify/MediaImage/23118015758387', + 'shopifyGid' => 'gid://shopify/MediaImage/23118015758387', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118015758387', @@ -2249,7 +2249,7 @@ ], 'ProductVariant_productvariant_41966466236467' => [ 'id' => 65046, - 'shopifyId' => 'gid://shopify/ProductVariant/41966466236467', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966466236467', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966466236467', @@ -2300,7 +2300,7 @@ ], 'MediaImage_mediaimage_23118019788851' => [ 'id' => 65081, - 'shopifyId' => 'gid://shopify/MediaImage/23118019788851', + 'shopifyGid' => 'gid://shopify/MediaImage/23118019788851', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118019788851', @@ -2323,7 +2323,7 @@ ], 'ProductVariant_productvariant_41966470201395' => [ 'id' => 65082, - 'shopifyId' => 'gid://shopify/ProductVariant/41966470201395', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966470201395', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966470201395', @@ -2374,7 +2374,7 @@ ], 'MediaImage_mediaimage_23118023426099' => [ 'id' => 65108, - 'shopifyId' => 'gid://shopify/MediaImage/23118023426099', + 'shopifyGid' => 'gid://shopify/MediaImage/23118023426099', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118023426099', @@ -2397,7 +2397,7 @@ ], 'ProductVariant_productvariant_41966471970867' => [ 'id' => 65109, - 'shopifyId' => 'gid://shopify/ProductVariant/41966471970867', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966471970867', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966471970867', @@ -2448,7 +2448,7 @@ ], 'MediaImage_mediaimage_23118091616307' => [ 'id' => 65621, - 'shopifyId' => 'gid://shopify/MediaImage/23118091616307', + 'shopifyGid' => 'gid://shopify/MediaImage/23118091616307', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118091616307', @@ -2471,7 +2471,7 @@ ], 'ProductVariant_productvariant_41966558183475' => [ 'id' => 65622, - 'shopifyId' => 'gid://shopify/ProductVariant/41966558183475', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966558183475', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966558183475', @@ -2522,7 +2522,7 @@ ], 'MediaImage_mediaimage_23118095417395' => [ 'id' => 65645, - 'shopifyId' => 'gid://shopify/MediaImage/23118095417395', + 'shopifyGid' => 'gid://shopify/MediaImage/23118095417395', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118095417395', @@ -2545,7 +2545,7 @@ ], 'ProductVariant_productvariant_41966567358515' => [ 'id' => 65646, - 'shopifyId' => 'gid://shopify/ProductVariant/41966567358515', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966567358515', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966567358515', @@ -2596,7 +2596,7 @@ ], 'MediaImage_mediaimage_23118112358451' => [ 'id' => 65774, - 'shopifyId' => 'gid://shopify/MediaImage/23118112358451', + 'shopifyGid' => 'gid://shopify/MediaImage/23118112358451', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118112358451', @@ -2619,7 +2619,7 @@ ], 'ProductVariant_productvariant_41966582956083' => [ 'id' => 65775, - 'shopifyId' => 'gid://shopify/ProductVariant/41966582956083', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966582956083', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966582956083', @@ -2670,7 +2670,7 @@ ], 'MediaImage_mediaimage_23118148141107' => [ 'id' => 66026, - 'shopifyId' => 'gid://shopify/MediaImage/23118148141107', + 'shopifyGid' => 'gid://shopify/MediaImage/23118148141107', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118148141107', @@ -2693,7 +2693,7 @@ ], 'ProductVariant_productvariant_41966614183987' => [ 'id' => 66027, - 'shopifyId' => 'gid://shopify/ProductVariant/41966614183987', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966614183987', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966614183987', diff --git a/tests/unit/jobs/ProcessBulkOperationDataTest.php b/tests/unit/jobs/ProcessBulkOperationDataTest.php index 28820f10..1b9129c1 100644 --- a/tests/unit/jobs/ProcessBulkOperationDataTest.php +++ b/tests/unit/jobs/ProcessBulkOperationDataTest.php @@ -79,7 +79,7 @@ public function testProcessItemCreatesShopifyDataRecordForVariant(): void $job = $this->_makeJob(); $job->callProcessItem($json); - $record = ShopifyData::findOne(['shopifyId' => $variantGid, 'parentId' => $productGid]); + $record = ShopifyData::findOne(['shopifyGid' => $variantGid, 'parentId' => $productGid]); self::assertNotNull($record); self::assertEquals('ProductVariant', $record->type); self::assertEquals($productGid, $record->parentId); @@ -107,7 +107,7 @@ public function testProcessItemUpdatesExistingShopifyDataRecord(): void ]); $job->callProcessItem($updatedJson); - $records = ShopifyData::find()->where(['shopifyId' => $variantGid, 'parentId' => $productGid])->all(); + $records = ShopifyData::find()->where(['shopifyGid' => $variantGid, 'parentId' => $productGid])->all(); // Should still only be one record (updated in place) self::assertCount(1, $records); } @@ -188,7 +188,7 @@ public function testProcessItemsFromRealBulkOperationJsonl(): void // createOrUpdateProduct requires a full element save — mock it out Plugin::getInstance()->set('products', $this->makeEmpty(Products::class, [ 'createOrUpdateProduct' => fn() => true, - 'deleteShopifyDataByShopifyId' => fn() => null, + 'deleteShopifyDataByShopifyGid' => fn() => null, ])); $job = $this->_makeJob(); @@ -207,7 +207,7 @@ public function testProcessItemsFromRealBulkOperationJsonl(): void self::assertEquals(60, $total); // Product row - $productRow = ShopifyData::find()->where(['shopifyId' => $productGid, 'type' => 'Product'])->one(); + $productRow = ShopifyData::find()->where(['shopifyGid' => $productGid, 'type' => 'Product'])->one(); self::assertNotNull($productRow); self::assertNull($productRow->parentId); @@ -245,7 +245,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void { // Insert a couple of rows that should be wiped \Yii::$app->db->createCommand()->insert(\craft\shopify\db\Table::DATA, [ - 'shopifyId' => 'gid://shopify/Product/before-clear-test-001', + 'shopifyGid' => 'gid://shopify/Product/before-clear-test-001', 'type' => 'Product', 'data' => json_encode(['id' => 'gid://shopify/Product/before-clear-test-001']), 'parentId' => null, @@ -256,7 +256,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void $service = Plugin::getInstance()->getBulkOperations(); $model = new BulkOperation(); - $model->shopifyId = self::BULK_OP_GID; + $model->shopifyGid = self::BULK_OP_GID; $model->query = 'query {}'; $model->clearData = 'none'; $model->setStatus(BulkOperationStatus::Created); @@ -271,7 +271,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void { \Yii::$app->db->createCommand()->insert(\craft\shopify\db\Table::DATA, [ - 'shopifyId' => 'gid://shopify/Product/before-none-test-001', + 'shopifyGid' => 'gid://shopify/Product/before-none-test-001', 'type' => 'Product', 'data' => json_encode(['id' => 'gid://shopify/Product/before-none-test-001']), 'parentId' => null, @@ -284,7 +284,7 @@ public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void $service = Plugin::getInstance()->getBulkOperations(); $model = new BulkOperation(); - $model->shopifyId = self::BULK_OP_GID; + $model->shopifyGid = self::BULK_OP_GID; $model->query = 'query {}'; $model->clearData = 'none'; $model->setStatus(BulkOperationStatus::Created); @@ -303,7 +303,7 @@ public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void private function _makeJob(string $clearData = 'none'): TestableProcessBulkOperationData { return new TestableProcessBulkOperationData([ - 'bulkOperationShopifyId' => self::BULK_OP_GID, + 'bulkOperationShopifyGid' => self::BULK_OP_GID, 'dataUrl' => 'https://storage.example.com/data.jsonl', 'objectCount' => 0, 'clearData' => $clearData, @@ -324,7 +324,7 @@ public function callProcessItem(mixed $item): void public function callBefore(): void { // Call only our override, not BaseBatchedJob::before() which requires queue context - $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId); + $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid); if (!$bulkOperation) { return; @@ -336,7 +336,7 @@ public function callBefore(): void if ($this->clearData === \craft\shopify\records\BulkOperation::CLEAR_DATA_ALL) { ShopifyData::deleteAll(); } elseif ($this->clearData !== \craft\shopify\records\BulkOperation::CLEAR_DATA_NONE) { - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->clearData); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->clearData); } } } diff --git a/tests/unit/services/BulkOperationsTest.php b/tests/unit/services/BulkOperationsTest.php index 5f3c4f86..d1c106e0 100644 --- a/tests/unit/services/BulkOperationsTest.php +++ b/tests/unit/services/BulkOperationsTest.php @@ -55,28 +55,28 @@ public function testGetAllBulkOperationsReturnsCollection(): void public function testGetAllBulkOperationsContainsFixtureData(): void { $ops = Plugin::getInstance()->getBulkOperations()->getAllBulkOperations(); - $shopifyIds = $ops->pluck('shopifyId')->all(); + $shopifyIds = $ops->pluck('shopifyGid')->all(); self::assertContains(self::FIXTURE_GID, $shopifyIds); } // ------------------------------------------------------------------------- - // getBulkOperationByShopifyId + // getBulkOperationByShopifyGid // ------------------------------------------------------------------------- - public function testGetBulkOperationByShopifyIdFindsKnownRecord(): void + public function testGetBulkOperationByShopifyGidFindsKnownRecord(): void { - $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId(self::FIXTURE_GID); + $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid(self::FIXTURE_GID); self::assertNotNull($op); self::assertInstanceOf(BulkOperation::class, $op); - self::assertEquals(self::FIXTURE_GID, $op->shopifyId); + self::assertEquals(self::FIXTURE_GID, $op->shopifyGid); self::assertEquals(BulkOperationStatus::Completed, $op->getStatus()); } - public function testGetBulkOperationByShopifyIdReturnsNullForUnknownId(): void + public function testGetBulkOperationByShopifyGidReturnsNullForUnknownId(): void { - $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId('gid://shopify/BulkOperation/does-not-exist'); + $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/does-not-exist'); self::assertNull($op); } @@ -98,12 +98,12 @@ public function testSaveBulkOperationCreatesNewRecord(): void public function testSaveBulkOperationUpdatesExistingRecord(): void { $service = Plugin::getInstance()->getBulkOperations(); - $op = $service->getBulkOperationByShopifyId(self::FIXTURE_GID); + $op = $service->getBulkOperationByShopifyGid(self::FIXTURE_GID); $op->objectCount = 9999; $service->saveBulkOperation($op, false); - $reloaded = $service->getBulkOperationByShopifyId(self::FIXTURE_GID); + $reloaded = $service->getBulkOperationByShopifyGid(self::FIXTURE_GID); self::assertEquals(9999, $reloaded->objectCount); } @@ -121,7 +121,7 @@ public function testDeleteBulkOperationByIdRemovesRecord(): void $result = $service->deleteBulkOperationById($id); self::assertTrue($result); - self::assertNull($service->getBulkOperationByShopifyId('gid://shopify/BulkOperation/delete-test-001')); + self::assertNull($service->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/delete-test-001')); } public function testDeleteBulkOperationByIdReturnsTrueForMissingRecord(): void @@ -140,7 +140,7 @@ public function testCannotDeleteProcessingBulkOperation(): void $result = $service->deleteBulkOperationById($model->id); self::assertFalse($result); - self::assertNotNull($service->getBulkOperationByShopifyId('gid://shopify/BulkOperation/processing-test-001')); + self::assertNotNull($service->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/processing-test-001')); } // ------------------------------------------------------------------------- @@ -180,7 +180,7 @@ public function testHandleBulkOperationFinishedMarksCanceledAsCompleted(): void $service->handleBulkOperationFinished(['admin_graphql_api_id' => $gid]); - $reloaded = $service->getBulkOperationByShopifyId($gid); + $reloaded = $service->getBulkOperationByShopifyGid($gid); self::assertNotNull($reloaded); self::assertEquals(BulkOperationStatus::Completed, $reloaded->getStatus()); self::assertEquals('CANCELED', $reloaded->shopifyStatus); @@ -207,7 +207,7 @@ public function testHandleBulkOperationFinishedStoresUrlAndObjectCountWhenComple $service->handleBulkOperationFinished(['admin_graphql_api_id' => $gid]); - $reloaded = $service->getBulkOperationByShopifyId($gid); + $reloaded = $service->getBulkOperationByShopifyGid($gid); self::assertNotNull($reloaded); self::assertEquals('COMPLETED', $reloaded->shopifyStatus); self::assertEquals($dataUrl, $reloaded->url); @@ -241,19 +241,19 @@ public function testQueueNextBulkOperationReturnsFalseWhenAlreadyProcessing(): v // Helpers // ------------------------------------------------------------------------- - private function _makeQueuedOp(string $shopifyId): BulkOperation + private function _makeQueuedOp(string $gid): BulkOperation { $model = new BulkOperation(); - $model->shopifyId = $shopifyId; + $model->shopifyGid = $gid; $model->query = 'query { products { edges { node { id } } } }'; $model->clearData = 'none'; $model->setStatus(BulkOperationStatus::Queued); return $model; } - private function _makeCreatedOp(string $shopifyId): BulkOperation + private function _makeCreatedOp(string $gid): BulkOperation { - $model = $this->_makeQueuedOp($shopifyId); + $model = $this->_makeQueuedOp($gid); $model->setStatus(BulkOperationStatus::Created); return $model; } diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php index bceb97a8..b3f16427 100644 --- a/tests/unit/services/ProductsTest.php +++ b/tests/unit/services/ProductsTest.php @@ -63,13 +63,13 @@ public function testNormalizeShopifyGidDoesNotDoublePrefix(): void } // ------------------------------------------------------------------------- - // deleteShopifyDataByShopifyId + // deleteShopifyDataByShopifyGid // ------------------------------------------------------------------------- - public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): void + public function testDeleteShopifyDataByShopifyGidRemovesProductAndChildren(): void { // Verify fixture data exists before deletion - $productRow = ShopifyData::find()->where(['shopifyId' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); self::assertNotNull($productRow, 'Fixture product row must exist before deletion test.'); $variantsBefore = ShopifyData::find() @@ -77,10 +77,10 @@ public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): voi ->count(); self::assertGreaterThan(0, $variantsBefore, 'Fixture must have at least one variant.'); - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId(self::PRODUCT_GID); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid(self::PRODUCT_GID); // Product row should be gone - $productRow = ShopifyData::find()->where(['shopifyId' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); self::assertNull($productRow); // All direct children (variants, images) should also be gone @@ -88,10 +88,10 @@ public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): voi self::assertEquals(0, $children); } - public function testDeleteShopifyDataByShopifyIdAcceptsNumericId(): void + public function testDeleteShopifyDataByShopifyGidAcceptsNumericId(): void { // Numeric ID should be normalized to GID before lookup — no exception expected - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId('7136060145715'); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid('7136060145715'); $this->assertTrue(true); } @@ -164,7 +164,7 @@ public function testEagerLoadMetafieldsForProductsMapsKeyValuePairs(): void // Insert a product row and a metafield child \Yii::$app->db->createCommand()->insert(Table::DATA, [ - 'shopifyId' => $productGid, + 'shopifyGid' => $productGid, 'type' => 'Product', 'data' => json_encode(['id' => $productGid, 'title' => 'Test']), 'parentId' => null, @@ -174,7 +174,7 @@ public function testEagerLoadMetafieldsForProductsMapsKeyValuePairs(): void ])->execute(); \Yii::$app->db->createCommand()->insert(Table::DATA, [ - 'shopifyId' => 'gid://shopify/Metafield/test-mf-1', + 'shopifyGid' => 'gid://shopify/Metafield/test-mf-1', 'type' => 'Metafield', 'data' => json_encode(['id' => 'gid://shopify/Metafield/test-mf-1', 'key' => 'my_key', 'value' => 'my_value']), 'parentId' => $productGid, From 42d0c30d08c6ab6c6be2dca7c6fbe8f841d1d439 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 18 Jun 2026 09:09:14 +0100 Subject: [PATCH 02/12] WIP PW tests --- package-lock.json | 27 +++++++++++++++ package.json | 1 + tests-playwright/.env.example | 23 +++++++++++++ tests-playwright/.gitignore | 2 ++ .../ddev-config/config.local.yaml | 3 ++ tests-playwright/index.js | 14 ++++++++ .../tests/navigation/index.test.js | 33 +++++++++++++++++++ 7 files changed, 103 insertions(+) create mode 100644 tests-playwright/.env.example create mode 100644 tests-playwright/.gitignore create mode 100644 tests-playwright/ddev-config/config.local.yaml create mode 100644 tests-playwright/index.js create mode 100644 tests-playwright/tests/navigation/index.test.js diff --git a/package-lock.json b/package-lock.json index 72cbe5de..245a46f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "@craftcms/shopify", "devDependencies": { + "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", @@ -13,6 +14,20 @@ "prettier": "^2.7.1" } }, + "../cms/packages/craftcms-playwright": { + "name": "@craftcms/playwright", + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@playwright/test": "^1.47.0", + "events": "^3.3.0", + "signale": "^1.4.0" + }, + "bin": { + "craft-playwright": "src/cli.js" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1835,6 +1850,10 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, + "node_modules/@craftcms/playwright": { + "resolved": "../cms/packages/craftcms-playwright", + "link": true + }, "node_modules/@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", @@ -12329,6 +12348,14 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, + "@craftcms/playwright": { + "version": "file:../cms/packages/craftcms-playwright", + "requires": { + "@playwright/test": "^1.47.0", + "events": "^3.3.0", + "signale": "^1.4.0" + } + }, "@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", diff --git a/package.json b/package.json index 4cd823e7..b050a33b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "extends @craftcms/browserslist-config" ], "devDependencies": { + "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", diff --git a/tests-playwright/.env.example b/tests-playwright/.env.example new file mode 100644 index 00000000..38220d16 --- /dev/null +++ b/tests-playwright/.env.example @@ -0,0 +1,23 @@ +CRAFT_APP_ID=craftcms +CRAFT_ENVIRONMENT=dev +CRAFT_SECURITY_KEY=qwerty1234567890 +CRAFT_CP_TRIGGER=admin + +PRIMARY_SITE_URL=https://playwright.ddev.site +AUTH_USERNAME=admin +AUTH_PASSWORD=NewPassword +REPO_PATH=../../../. + +# the actual namespace of the *Fixture.php files +CODECEPTION_FIXTURES_NAMESPACE='crafttests\fixtures' +# the location of the *Fixture.php files relative to the root of your repo +CODECEPTION_FIXTURES_PATH=tests/fixtures + +CRAFT_DB_DRIVER="mysql" +CRAFT_DB_SERVER="db" +CRAFT_DB_PORT="3306" +CRAFT_DB_DATABASE="db" +CRAFT_DB_USER="db" +CRAFT_DB_PASSWORD="db" +CRAFT_DB_SCHEMA="public" +CRAFT_DB_TABLE_PREFIX="" \ No newline at end of file diff --git a/tests-playwright/.gitignore b/tests-playwright/.gitignore new file mode 100644 index 00000000..fa04e32d --- /dev/null +++ b/tests-playwright/.gitignore @@ -0,0 +1,2 @@ +/.env +/.authentication.json \ No newline at end of file diff --git a/tests-playwright/ddev-config/config.local.yaml b/tests-playwright/ddev-config/config.local.yaml new file mode 100644 index 00000000..6fc1568d --- /dev/null +++ b/tests-playwright/ddev-config/config.local.yaml @@ -0,0 +1,3 @@ +host_db_port: '33069' +# if you wish to rename your test project from the default "playwright", +# you need to both add a name to this file and specify a matching value under PRIMARY_SITE_URL in the .env file diff --git a/tests-playwright/index.js b/tests-playwright/index.js new file mode 100644 index 00000000..909af02a --- /dev/null +++ b/tests-playwright/index.js @@ -0,0 +1,14 @@ +/* jshint esversion: 9, strict: false */ +/* globals module, require */ +const craftPlaywright = require('@craftcms/playwright'); + +craftPlaywright.test = craftPlaywright.test.extend({ + // Here there is the ability to extend the test object +}); + +// You can listen to events here +// craftPlaywright.events.cleanAll.on('before', async () => { +// process.stdout.write('--- Before Clean All --- \n'); +// }); + +module.exports = craftPlaywright; diff --git a/tests-playwright/tests/navigation/index.test.js b/tests-playwright/tests/navigation/index.test.js new file mode 100644 index 00000000..4aacdd8e --- /dev/null +++ b/tests-playwright/tests/navigation/index.test.js @@ -0,0 +1,33 @@ +/* jshint esversion: 9, strict: false */ +/* globals module, require */ +const {test, expect} = require('../../index'); + +test.beforeEach(async ({craftDashboard}) => { + await craftDashboard.goTo(); +}); + +test.describe('Navigation', () => { + const navItems = [ + ['Shopify', 'Settings'], + ]; + + test('Global navigation has expected links', async ({page}) => { + await expect(page.locator('#global-sidebar nav ul li a')).toContainText( + navItems.map((item) => (Array.isArray(item) ? item[0] : item)) + ); + }); + + test('Navigation items go to the correct pages', async ({ + craftDashboard, + page, + }) => { + for (let i = 0; i < navItems.length; i++) { + await craftDashboard.goTo(); + let text = Array.isArray(navItems[i]) ? navItems[i][0] : navItems[i]; + let title = Array.isArray(navItems[i]) ? navItems[i][1] : text; + + await page.click('#global-sidebar nav ul li a:has-text("' + text + '")'); + await expect(page.locator('h1')).toContainText(title); + } + }); +}); From 947fd928f5f23c0056ff4f41ab914a72b07e0ba3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 13:52:34 +0100 Subject: [PATCH 03/12] bump deps --- composer.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index 5ca2dbe1..b7b8331d 100644 --- a/composer.lock +++ b/composer.lock @@ -538,16 +538,16 @@ }, { "name": "craftcms/cms", - "version": "5.10.7", + "version": "5.10.8", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "6a08356668f772f0a4d79769d3de83508cd4e21c" + "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/6a08356668f772f0a4d79769d3de83508cd4e21c", - "reference": "6a08356668f772f0a4d79769d3de83508cd4e21c", + "url": "https://api.github.com/repos/craftcms/cms/zipball/ec387a529a7f9a0c07f2a086c59dadc02fc79556", + "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556", "shasum": "" }, "require": { @@ -664,7 +664,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-18T01:14:32+00:00" + "time": "2026-06-23T11:02:47+00:00" }, { "name": "craftcms/plugin-installer", From c281062242db2cb1f63df4895410d59f798d0624 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 14:05:07 +0100 Subject: [PATCH 04/12] Revert "WIP PW tests" This reverts commit 42d0c30d08c6ab6c6be2dca7c6fbe8f841d1d439. --- package-lock.json | 27 --------------- package.json | 1 - tests-playwright/.env.example | 23 ------------- tests-playwright/.gitignore | 2 -- .../ddev-config/config.local.yaml | 3 -- tests-playwright/index.js | 14 -------- .../tests/navigation/index.test.js | 33 ------------------- 7 files changed, 103 deletions(-) delete mode 100644 tests-playwright/.env.example delete mode 100644 tests-playwright/.gitignore delete mode 100644 tests-playwright/ddev-config/config.local.yaml delete mode 100644 tests-playwright/index.js delete mode 100644 tests-playwright/tests/navigation/index.test.js diff --git a/package-lock.json b/package-lock.json index 245a46f7..72cbe5de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "@craftcms/shopify", "devDependencies": { - "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", @@ -14,20 +13,6 @@ "prettier": "^2.7.1" } }, - "../cms/packages/craftcms-playwright": { - "name": "@craftcms/playwright", - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@playwright/test": "^1.47.0", - "events": "^3.3.0", - "signale": "^1.4.0" - }, - "bin": { - "craft-playwright": "src/cli.js" - } - }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1850,10 +1835,6 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, - "node_modules/@craftcms/playwright": { - "resolved": "../cms/packages/craftcms-playwright", - "link": true - }, "node_modules/@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", @@ -12348,14 +12329,6 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, - "@craftcms/playwright": { - "version": "file:../cms/packages/craftcms-playwright", - "requires": { - "@playwright/test": "^1.47.0", - "events": "^3.3.0", - "signale": "^1.4.0" - } - }, "@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", diff --git a/package.json b/package.json index b050a33b..4cd823e7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "extends @craftcms/browserslist-config" ], "devDependencies": { - "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", diff --git a/tests-playwright/.env.example b/tests-playwright/.env.example deleted file mode 100644 index 38220d16..00000000 --- a/tests-playwright/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -CRAFT_APP_ID=craftcms -CRAFT_ENVIRONMENT=dev -CRAFT_SECURITY_KEY=qwerty1234567890 -CRAFT_CP_TRIGGER=admin - -PRIMARY_SITE_URL=https://playwright.ddev.site -AUTH_USERNAME=admin -AUTH_PASSWORD=NewPassword -REPO_PATH=../../../. - -# the actual namespace of the *Fixture.php files -CODECEPTION_FIXTURES_NAMESPACE='crafttests\fixtures' -# the location of the *Fixture.php files relative to the root of your repo -CODECEPTION_FIXTURES_PATH=tests/fixtures - -CRAFT_DB_DRIVER="mysql" -CRAFT_DB_SERVER="db" -CRAFT_DB_PORT="3306" -CRAFT_DB_DATABASE="db" -CRAFT_DB_USER="db" -CRAFT_DB_PASSWORD="db" -CRAFT_DB_SCHEMA="public" -CRAFT_DB_TABLE_PREFIX="" \ No newline at end of file diff --git a/tests-playwright/.gitignore b/tests-playwright/.gitignore deleted file mode 100644 index fa04e32d..00000000 --- a/tests-playwright/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/.env -/.authentication.json \ No newline at end of file diff --git a/tests-playwright/ddev-config/config.local.yaml b/tests-playwright/ddev-config/config.local.yaml deleted file mode 100644 index 6fc1568d..00000000 --- a/tests-playwright/ddev-config/config.local.yaml +++ /dev/null @@ -1,3 +0,0 @@ -host_db_port: '33069' -# if you wish to rename your test project from the default "playwright", -# you need to both add a name to this file and specify a matching value under PRIMARY_SITE_URL in the .env file diff --git a/tests-playwright/index.js b/tests-playwright/index.js deleted file mode 100644 index 909af02a..00000000 --- a/tests-playwright/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/* jshint esversion: 9, strict: false */ -/* globals module, require */ -const craftPlaywright = require('@craftcms/playwright'); - -craftPlaywright.test = craftPlaywright.test.extend({ - // Here there is the ability to extend the test object -}); - -// You can listen to events here -// craftPlaywright.events.cleanAll.on('before', async () => { -// process.stdout.write('--- Before Clean All --- \n'); -// }); - -module.exports = craftPlaywright; diff --git a/tests-playwright/tests/navigation/index.test.js b/tests-playwright/tests/navigation/index.test.js deleted file mode 100644 index 4aacdd8e..00000000 --- a/tests-playwright/tests/navigation/index.test.js +++ /dev/null @@ -1,33 +0,0 @@ -/* jshint esversion: 9, strict: false */ -/* globals module, require */ -const {test, expect} = require('../../index'); - -test.beforeEach(async ({craftDashboard}) => { - await craftDashboard.goTo(); -}); - -test.describe('Navigation', () => { - const navItems = [ - ['Shopify', 'Settings'], - ]; - - test('Global navigation has expected links', async ({page}) => { - await expect(page.locator('#global-sidebar nav ul li a')).toContainText( - navItems.map((item) => (Array.isArray(item) ? item[0] : item)) - ); - }); - - test('Navigation items go to the correct pages', async ({ - craftDashboard, - page, - }) => { - for (let i = 0; i < navItems.length; i++) { - await craftDashboard.goTo(); - let text = Array.isArray(navItems[i]) ? navItems[i][0] : navItems[i]; - let title = Array.isArray(navItems[i]) ? navItems[i][1] : text; - - await page.click('#global-sidebar nav ul li a:has-text("' + text + '")'); - await expect(page.locator('h1')).toContainText(title); - } - }); -}); From 19b60ceeb35fafa5b62c8212e2bd1b8d5aedbe01 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 15:23:28 +0100 Subject: [PATCH 05/12] fix cs --- src/services/Products.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/Products.php b/src/services/Products.php index 9ad9af73..d103c875 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -13,7 +13,6 @@ use craft\helpers\StringHelper; use craft\models\FieldLayout; use craft\shopify\collections\VariantCollection; -use craft\shopify\db\Table; use craft\shopify\elements\Product; use craft\shopify\events\ShopifyProductSyncEvent; use craft\shopify\models\Variant; From 810561e79350cd003fabebeb2111da516d300a74 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 17:14:26 +0100 Subject: [PATCH 06/12] More tests --- tests/unit/models/BulkOperationTest.php | 85 ++++++++++++++ tests/unit/models/VariantTest.php | 123 +++++++++++++++++++++ tests/unit/services/BulkOperationsTest.php | 16 +++ tests/unit/services/ProductsTest.php | 18 +++ 4 files changed, 242 insertions(+) create mode 100644 tests/unit/models/BulkOperationTest.php create mode 100644 tests/unit/models/VariantTest.php diff --git a/tests/unit/models/BulkOperationTest.php b/tests/unit/models/BulkOperationTest.php new file mode 100644 index 00000000..5cb24220 --- /dev/null +++ b/tests/unit/models/BulkOperationTest.php @@ -0,0 +1,85 @@ + ['class' => BulkOperationsFixture::class], + ]; + } + + private function _getFixtureOp(): BulkOperation + { + return Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid(self::FIXTURE_GID); + } + + // ------------------------------------------------------------------------- + // shopifyGid + // ------------------------------------------------------------------------- + + public function testShopifyGidIsString(): void + { + $op = $this->_getFixtureOp(); + self::assertIsString($op->shopifyGid); + } + + public function testShopifyGidHasCorrectFormat(): void + { + $op = $this->_getFixtureOp(); + self::assertStringStartsWith('gid://shopify/BulkOperation/', $op->shopifyGid); + } + + public function testShopifyGidMatchesFixture(): void + { + $op = $this->_getFixtureOp(); + self::assertEquals(self::FIXTURE_GID, $op->shopifyGid); + } + + public function testShopifyGidNumericSegmentIsNumeric(): void + { + $op = $this->_getFixtureOp(); + $lastSegment = substr($op->shopifyGid, strrpos($op->shopifyGid, '/') + 1); + self::assertMatchesRegularExpression('/^\d+$/', $lastSegment); + } + + // ------------------------------------------------------------------------- + // shopifyGid set on new model + // ------------------------------------------------------------------------- + + public function testShopifyGidCanBeSetOnNewModel(): void + { + $model = new BulkOperation(); + $model->shopifyGid = self::FIXTURE_GID; + + self::assertEquals(self::FIXTURE_GID, $model->shopifyGid); + self::assertIsString($model->shopifyGid); + } + + public function testShopifyGidIsNullByDefault(): void + { + $model = new BulkOperation(); + self::assertNull($model->shopifyGid); + } +} diff --git a/tests/unit/models/VariantTest.php b/tests/unit/models/VariantTest.php new file mode 100644 index 00000000..9002447c --- /dev/null +++ b/tests/unit/models/VariantTest.php @@ -0,0 +1,123 @@ + ['class' => ShopifyDataFixture::class], + ]; + } + + private function _getFirstVariant(): \craft\shopify\models\Variant + { + $products = $this->_makeMockProduct(self::PRODUCT_GID); + Plugin::getInstance()->getProducts()->eagerLoadVariantsForProducts([$products]); + return $products->variants->first(); + } + + // ------------------------------------------------------------------------- + // shopifyGid + // ------------------------------------------------------------------------- + + public function testShopifyGidIsString(): void + { + $variant = $this->_getFirstVariant(); + self::assertIsString($variant->shopifyGid); + } + + public function testShopifyGidHasCorrectFormat(): void + { + $variant = $this->_getFirstVariant(); + self::assertStringStartsWith('gid://shopify/ProductVariant/', $variant->shopifyGid); + } + + public function testShopifyGidMatchesFixture(): void + { + $variant = $this->_getFirstVariant(); + self::assertEquals(self::VARIANT_GID, $variant->shopifyGid); + } + + // ------------------------------------------------------------------------- + // shopifyId + // ------------------------------------------------------------------------- + + public function testShopifyIdIsString(): void + { + $variant = $this->_getFirstVariant(); + self::assertIsString($variant->shopifyId); + } + + public function testShopifyIdIsNumeric(): void + { + $variant = $this->_getFirstVariant(); + self::assertMatchesRegularExpression('/^\d+$/', $variant->shopifyId); + } + + public function testShopifyIdMatchesFixture(): void + { + $variant = $this->_getFirstVariant(); + self::assertEquals(self::VARIANT_ID, $variant->shopifyId); + } + + // ------------------------------------------------------------------------- + // shopifyId and shopifyGid relationship + // ------------------------------------------------------------------------- + + public function testShopifyIdIsLastSegmentOfGid(): void + { + $variant = $this->_getFirstVariant(); + $lastSegment = substr($variant->shopifyGid, strrpos($variant->shopifyGid, '/') + 1); + self::assertEquals($lastSegment, $variant->shopifyId); + } + + public function testShopifyGidContainsShopifyId(): void + { + $variant = $this->_getFirstVariant(); + self::assertStringContainsString($variant->shopifyId, $variant->shopifyGid); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function _makeMockProduct(string $gid): object + { + return new class($gid) { + public string $shopifyGid; + public ?VariantCollection $variants = null; + + public function __construct(string $gid) + { + $this->shopifyGid = $gid; + } + + public function setVariants(VariantCollection $variants): void + { + $this->variants = $variants; + } + }; + } +} diff --git a/tests/unit/services/BulkOperationsTest.php b/tests/unit/services/BulkOperationsTest.php index d1c106e0..7553249a 100644 --- a/tests/unit/services/BulkOperationsTest.php +++ b/tests/unit/services/BulkOperationsTest.php @@ -81,6 +81,22 @@ public function testGetBulkOperationByShopifyGidReturnsNullForUnknownId(): void self::assertNull($op); } + // ------------------------------------------------------------------------- + // getBulkOperationByShopifyId (deprecated) + // ------------------------------------------------------------------------- + + /** + * @deprecated in 8.0.0. Use [[testGetBulkOperationByShopifyGidFindsKnownRecord()]] instead. + */ + public function testGetBulkOperationByShopifyIdDelegatesToGidMethod(): void + { + $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId(self::FIXTURE_GID); + + self::assertNotNull($op); + self::assertInstanceOf(BulkOperation::class, $op); + self::assertEquals(self::FIXTURE_GID, $op->shopifyGid); + } + // ------------------------------------------------------------------------- // saveBulkOperation // ------------------------------------------------------------------------- diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php index b3f16427..a9e36040 100644 --- a/tests/unit/services/ProductsTest.php +++ b/tests/unit/services/ProductsTest.php @@ -95,6 +95,24 @@ public function testDeleteShopifyDataByShopifyGidAcceptsNumericId(): void $this->assertTrue(true); } + // ------------------------------------------------------------------------- + // deleteShopifyDataByShopifyId (deprecated) + // ------------------------------------------------------------------------- + + /** + * @deprecated in 8.0.0. Use [[testDeleteShopifyDataByShopifyGidRemovesProductAndChildren()]] instead. + */ + public function testDeleteShopifyDataByShopifyIdDelegatesToGidMethod(): void + { + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + self::assertNotNull($productRow, 'Fixture product row must exist before deletion test.'); + + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId(self::PRODUCT_GID); + + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + self::assertNull($productRow); + } + // ------------------------------------------------------------------------- // eagerLoadVariantsForProducts // ------------------------------------------------------------------------- From 33175e3dd017cde38b2c1f4f64d2a2696f46f9c8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 25 Jun 2026 07:32:31 +0100 Subject: [PATCH 07/12] Metafield normalization tidy --- src/elements/Product.php | 19 +- src/helpers/Metafield.php | 53 ++++ src/models/Variant.php | 24 +- src/services/Products.php | 31 +-- tests/unit/elements/ProductTest.php | 398 +++++++++++++++++++++++++++ tests/unit/services/ProductsTest.php | 3 +- 6 files changed, 474 insertions(+), 54 deletions(-) create mode 100644 src/helpers/Metafield.php create mode 100644 tests/unit/elements/ProductTest.php diff --git a/src/elements/Product.php b/src/elements/Product.php index d48d7ffb..7a467c20 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -23,6 +23,7 @@ use craft\shopify\fieldlayoutelements\MetafieldsField; use craft\shopify\fieldlayoutelements\OptionsField; use craft\shopify\fieldlayoutelements\VariantsField; +use craft\shopify\helpers\Metafield as MetafieldHelper; use craft\shopify\helpers\Product as ProductHelper; use craft\shopify\models\Variant; use craft\shopify\Plugin; @@ -359,8 +360,9 @@ public function getOptions(): array } /** - * @param string|array $value + * @param string|array $value A list-shaped array of `{key, value}` objects, or a JSON-encoded string of the same. * @return void + * @throws \InvalidArgumentException if the value is not a list-shaped array or JSON string of one. */ public function setMetafields(string|array $value): void { @@ -368,7 +370,11 @@ public function setMetafields(string|array $value): void $value = Json::decodeIfJson($value); } - $this->_metaFields = $value; + if (!is_array($value) || !array_is_list($value)) { + throw new \InvalidArgumentException('setMetafields() expects a list-shaped array of {key, value} objects or a JSON-encoded string of the same.'); + } + + $this->_metaFields = MetafieldHelper::normalizeToMap($value); } /** @@ -387,14 +393,7 @@ public function getMetafields(): array $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); - $metafields = $data - ->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); - - $this->setMetafields($metafields); + $this->setMetafields($data->all()); return $this->_metaFields ?? []; } diff --git a/src/helpers/Metafield.php b/src/helpers/Metafield.php new file mode 100644 index 00000000..ba243427 --- /dev/null +++ b/src/helpers/Metafield.php @@ -0,0 +1,53 @@ + + * @since 8.0.0 + */ +class Metafield +{ + /** + * Normalizes an iterable of metafield data into a flat key => value map. + * + * Accepts either: + * - [[ShopifyData]] ActiveRecord objects (from [[Api::getShopifyDataByType()]] with `$returnRecords = true`), + * where each record's `data` column holds a JSON-encoded `{key, value}` object. + * - Pre-decoded associative arrays (from [[Api::getShopifyDataByType()]] without `$returnRecords`), + * where each item already has `key` and `value` keys. + * + * Rows that do not carry both `key` and `value` are silently skipped. + * + * @param iterable $rows + * @return array + */ + public static function normalizeToMap(iterable $rows): array + { + return collect($rows) + ->mapWithKeys(function($d) { + $data = match (true) { + $d instanceof ShopifyData => Json::decodeIfJson($d->data), + is_string($d) => Json::decodeIfJson($d), + default => $d, + }; + + if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) { + return []; + } + + return [$data['key'] => Json::decodeIfJson($data['value'])]; + }) + ->all(); + } +} diff --git a/src/models/Variant.php b/src/models/Variant.php index 991ca967..f07470f4 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -9,6 +9,7 @@ use craft\base\Model; use craft\helpers\Json; +use craft\shopify\helpers\Metafield as MetafieldHelper; use craft\shopify\Plugin; use DateTime; use yii\base\InvalidConfigException; @@ -148,21 +149,21 @@ public function getData(): array } /** - * @param string|array $value + * @param string|array $value A list-shaped array of `{key, value}` objects, or a JSON-encoded string of the same. * @return void + * @throws \InvalidArgumentException if the value is not a list-shaped array or JSON string of one. */ public function setMetafields(string|array $value): void { if (is_string($value)) { $value = Json::decodeIfJson($value); - $value = collect($value)->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); } - $this->_metaFields = $value; + if (!is_array($value) || !array_is_list($value)) { + throw new \InvalidArgumentException('setMetafields() expects a list-shaped array of {key, value} objects or a JSON-encoded string of the same.'); + } + + $this->_metaFields = MetafieldHelper::normalizeToMap($value); } /** @@ -181,14 +182,7 @@ public function getMetafields(): array $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); - $metafields = $data - ->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); - - $this->setMetafields($metafields->all()); + $this->setMetafields($data->all()); return $this->_metaFields ?? []; } diff --git a/src/services/Products.php b/src/services/Products.php index d103c875..65a9bea3 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -8,7 +8,6 @@ use craft\events\ConfigEvent; use craft\helpers\ArrayHelper; use craft\helpers\Db; -use craft\helpers\Json; use craft\helpers\ProjectConfig; use craft\helpers\StringHelper; use craft\models\FieldLayout; @@ -281,23 +280,7 @@ public function deleteShopifyDataByShopifyId(string $shopifyId): void public function eagerLoadMetafieldsForProducts(array $products): array { return $this->_eagerLoadTypeOnProducts($products, 'Metafield', function($product, $rows) { - $metafields = collect($rows) - ->mapWithKeys(function($d, $key) { - /** @var ShopifyData $d */ - $data = Json::decodeIfJson($d->data); - - // Map if the data has `key` and `value` properties - if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) { - return []; - } - - return [ - $data['key'] => Json::decodeIfJson($data['value']), - ]; - }) - ->all(); - - $product->setMetafields($metafields); + $product->setMetafields($rows); }); } @@ -343,18 +326,10 @@ public function eagerLoadVariantsForProducts(array $products): array $variants = VariantCollection::make($variantsByProductId[$product->shopifyGid]); if ($metafieldsData->isNotEmpty()) { - $variants->map(function(Variant$variant) use ($metafieldsData) { + $variants->map(function(Variant $variant) use ($metafieldsData) { $metafields = $metafieldsData->get($variant->shopifyGid); if (!empty($metafields)) { - $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) { - $data = Json::decodeIfJson($d->data); - if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) { - return []; - } - return [ - $data['key'] => Json::decodeIfJson($data['value']), - ]; - })->all()); + $variant->setMetafields($metafields->all()); } }); } diff --git a/tests/unit/elements/ProductTest.php b/tests/unit/elements/ProductTest.php new file mode 100644 index 00000000..3d477b81 --- /dev/null +++ b/tests/unit/elements/ProductTest.php @@ -0,0 +1,398 @@ +shopifyGid = self::PRODUCT_GID; + $product->shopifyId = self::PRODUCT_ID; + $product->shopifyStatus = $shopifyStatus; + return $product; + } + + // ------------------------------------------------------------------------- + // shopifyId + // ------------------------------------------------------------------------- + + public function testShopifyIdIsNullByDefault(): void + { + $product = new Product(); + self::assertNull($product->shopifyId); + } + + public function testShopifyIdIsInt(): void + { + $product = $this->_makeProduct(); + self::assertIsInt($product->shopifyId); + } + + public function testShopifyIdCanBeSetOnNewElement(): void + { + $product = new Product(); + $product->shopifyId = self::PRODUCT_ID; + self::assertEquals(self::PRODUCT_ID, $product->shopifyId); + } + + // ------------------------------------------------------------------------- + // shopifyGid + // ------------------------------------------------------------------------- + + public function testShopifyGidIsNullByDefault(): void + { + $product = new Product(); + self::assertNull($product->shopifyGid); + } + + public function testShopifyGidIsString(): void + { + $product = $this->_makeProduct(); + self::assertIsString($product->shopifyGid); + } + + public function testShopifyGidHasCorrectFormat(): void + { + $product = $this->_makeProduct(); + self::assertStringStartsWith('gid://shopify/Product/', $product->shopifyGid); + } + + public function testShopifyGidCanBeSetOnNewElement(): void + { + $product = new Product(); + $product->shopifyGid = self::PRODUCT_GID; + self::assertEquals(self::PRODUCT_GID, $product->shopifyGid); + } + + public function testShopifyGidNumericSegmentIsNumeric(): void + { + $product = $this->_makeProduct(); + $lastSegment = substr($product->shopifyGid, strrpos($product->shopifyGid, '/') + 1); + self::assertMatchesRegularExpression('/^\d+$/', $lastSegment); + } + + // ------------------------------------------------------------------------- + // shopifyId and shopifyGid relationship + // ------------------------------------------------------------------------- + + public function testShopifyIdMatchesNumericSegmentOfGid(): void + { + $product = $this->_makeProduct(); + $lastSegment = (int) substr($product->shopifyGid, strrpos($product->shopifyGid, '/') + 1); + self::assertEquals($lastSegment, $product->shopifyId); + } + + public function testShopifyGidContainsShopifyId(): void + { + $product = $this->_makeProduct(); + self::assertStringContainsString((string) $product->shopifyId, $product->shopifyGid); + } + + // ------------------------------------------------------------------------- + // shopifyStatus + // ------------------------------------------------------------------------- + + public function testShopifyStatusDefaultsToActive(): void + { + $product = new Product(); + self::assertEquals(Product::SHOPIFY_STATUS_ACTIVE, $product->shopifyStatus); + } + + public function testShopifyStatusActiveConstantValue(): void + { + self::assertEquals('active', Product::SHOPIFY_STATUS_ACTIVE); + } + + public function testShopifyStatusDraftConstantValue(): void + { + self::assertEquals('draft', Product::SHOPIFY_STATUS_DRAFT); + } + + public function testShopifyStatusArchivedConstantValue(): void + { + self::assertEquals('archived', Product::SHOPIFY_STATUS_ARCHIVED); + } + + public function testShopifyStatusCanBeSetToDraft(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_DRAFT); + self::assertEquals(Product::SHOPIFY_STATUS_DRAFT, $product->shopifyStatus); + } + + public function testShopifyStatusCanBeSetToArchived(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_ARCHIVED); + self::assertEquals(Product::SHOPIFY_STATUS_ARCHIVED, $product->shopifyStatus); + } + + // ------------------------------------------------------------------------- + // getStatus() + // ------------------------------------------------------------------------- + + public function testGetStatusReturnsLiveWhenEnabledAndActive(): void + { + $product = $this->_makeProduct(); + $product->enabled = true; + self::assertEquals(Product::STATUS_LIVE, $product->getStatus()); + } + + public function testGetStatusReturnsShopifyDraftWhenEnabledAndDraft(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_DRAFT); + $product->enabled = true; + self::assertEquals(Product::STATUS_SHOPIFY_DRAFT, $product->getStatus()); + } + + public function testGetStatusReturnsShopifyArchivedWhenEnabledAndArchived(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_ARCHIVED); + $product->enabled = true; + self::assertEquals(Product::STATUS_SHOPIFY_ARCHIVED, $product->getStatus()); + } + + public function testGetStatusReturnsDisabledWhenNotEnabled(): void + { + $product = $this->_makeProduct(); + $product->enabled = false; + self::assertEquals(Product::STATUS_DISABLED, $product->getStatus()); + } + + public function testGetStatusReturnsDisabledRegardlessOfShopifyStatus(): void + { + foreach ([Product::SHOPIFY_STATUS_ACTIVE, Product::SHOPIFY_STATUS_DRAFT, Product::SHOPIFY_STATUS_ARCHIVED] as $shopifyStatus) { + $product = $this->_makeProduct($shopifyStatus); + $product->enabled = false; + self::assertEquals(Product::STATUS_DISABLED, $product->getStatus(), "Expected disabled status for shopifyStatus=$shopifyStatus"); + } + } + + // ------------------------------------------------------------------------- + // tags + // ------------------------------------------------------------------------- + + public function testGetTagsReturnsEmptyArrayByDefault(): void + { + $product = new Product(); + self::assertSame([], $product->getTags()); + } + + public function testSetTagsAcceptsArray(): void + { + $product = new Product(); + $product->setTags(['sale', 'new']); + self::assertSame(['sale', 'new'], $product->getTags()); + } + + public function testSetTagsDecodesJsonString(): void + { + $product = new Product(); + $product->setTags('["sale","new"]'); + self::assertSame(['sale', 'new'], $product->getTags()); + } + + // ------------------------------------------------------------------------- + // options + // ------------------------------------------------------------------------- + + public function testGetOptionsReturnsEmptyArrayByDefault(): void + { + $product = new Product(); + self::assertSame([], $product->getOptions()); + } + + public function testSetOptionsAcceptsArray(): void + { + $product = new Product(); + $options = [['name' => 'Size', 'values' => ['S', 'M', 'L']]]; + $product->setOptions($options); + self::assertSame($options, $product->getOptions()); + } + + public function testSetOptionsDecodesJsonString(): void + { + $product = new Product(); + $product->setOptions('[{"name":"Size","values":["S","M"]}]'); + self::assertEquals('Size', $product->getOptions()[0]['name']); + self::assertSame(['S', 'M'], $product->getOptions()[0]['values']); + } + + // ------------------------------------------------------------------------- + // data + // ------------------------------------------------------------------------- + + public function testGetDataReturnsEmptyArrayByDefault(): void + { + $product = new Product(); + self::assertSame([], $product->getData()); + } + + public function testSetDataAcceptsArray(): void + { + $product = new Product(); + $product->setData(['title' => 'Test Product']); + self::assertSame(['title' => 'Test Product'], $product->getData()); + } + + public function testSetDataDecodesJsonString(): void + { + $product = new Product(); + $product->setData('{"title":"Test Product"}'); + self::assertEquals('Test Product', $product->getData()['title']); + } + + public function testSetDataNullResultsInEmptyArray(): void + { + $product = new Product(); + $product->setData(['title' => 'Test Product']); + $product->setData(null); + self::assertSame([], $product->getData()); + } + + // ------------------------------------------------------------------------- + // descriptionHtml + // ------------------------------------------------------------------------- + + public function testGetDescriptionHtmlReturnsNullWhenNoData(): void + { + $product = new Product(); + self::assertNull($product->getDescriptionHtml()); + } + + public function testGetDescriptionHtmlReturnsValueFromData(): void + { + $product = new Product(); + $product->setData(['descriptionHtml' => '

Hello

']); + self::assertEquals('

Hello

', $product->getDescriptionHtml()); + } + + // ------------------------------------------------------------------------- + // variants + // ------------------------------------------------------------------------- + + public function testGetVariantsReturnsEmptyCollectionWithNoShopifyGid(): void + { + $product = new Product(); + self::assertCount(0, $product->getVariants()); + } + + public function testSetVariantsWrapsPlainArrayInVariantCollection(): void + { + $product = $this->_makeProduct(); + $product->setVariants([]); + self::assertInstanceOf(VariantCollection::class, $product->getVariants()); + } + + public function testSetVariantsKeepsExistingVariantCollection(): void + { + $product = $this->_makeProduct(); + $collection = VariantCollection::make(); + $product->setVariants($collection); + self::assertSame($collection, $product->getVariants()); + } + + // ------------------------------------------------------------------------- + // images + // ------------------------------------------------------------------------- + + public function testGetImagesReturnsEmptyArrayWhenNoShopifyGid(): void + { + $product = new Product(); + self::assertSame([], $product->getImages()); + } + + public function testSetImagesAcceptsArray(): void + { + $product = $this->_makeProduct(); + $images = [['url' => 'https://example.com/img.jpg']]; + $product->setImages($images); + self::assertSame($images, $product->getImages()); + } + + public function testSetImagesDecodesJsonString(): void + { + $product = $this->_makeProduct(); + $product->setImages('[{"url":"https://example.com/img.jpg"}]'); + self::assertIsArray($product->getImages()); + self::assertCount(1, $product->getImages()); + } + + // ------------------------------------------------------------------------- + // metafields + // ------------------------------------------------------------------------- + + public function testGetMetafieldsReturnsEmptyArrayWhenNoShopifyGid(): void + { + $product = new Product(); + self::assertSame([], $product->getMetafields()); + } + + public function testSetMetafieldsAcceptsListArray(): void + { + $product = $this->_makeProduct(); + $product->setMetafields([['key' => 'colour', 'value' => 'red']]); + self::assertSame(['colour' => 'red'], $product->getMetafields()); + } + + public function testSetMetafieldsDecodesJsonListString(): void + { + $product = $this->_makeProduct(); + $product->setMetafields('[{"key":"colour","value":"red"}]'); + self::assertSame(['colour' => 'red'], $product->getMetafields()); + } + + public function testSetMetafieldsNormalizesRawListFormat(): void + { + $product = $this->_makeProduct(); + $product->setMetafields([['key' => 'colour', 'value' => 'red']]); + self::assertSame(['colour' => 'red'], $product->getMetafields()); + } + + public function testSetMetafieldsThrowsForAssociativeArray(): void + { + $product = new Product(); + self::expectException(\InvalidArgumentException::class); + $product->setMetafields(['colour' => 'red']); + } + + public function testSetMetafieldsThrowsForJsonEncodedAssociativeArray(): void + { + $product = new Product(); + self::expectException(\InvalidArgumentException::class); + $product->setMetafields('{"colour":"red"}'); + } + + // ------------------------------------------------------------------------- + // getCheapestVariant / getDefaultVariant + // ------------------------------------------------------------------------- + + public function testGetDefaultVariantReturnsNullWhenNoVariants(): void + { + $product = new Product(); + self::assertNull($product->getDefaultVariant()); + } + + public function testGetCheapestVariantReturnsNullWhenNoVariants(): void + { + $product = new Product(); + self::assertNull($product->getCheapestVariant()); + } +} diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php index a9e36040..60e898b6 100644 --- a/tests/unit/services/ProductsTest.php +++ b/tests/unit/services/ProductsTest.php @@ -10,6 +10,7 @@ use Codeception\Test\Unit; use craft\shopify\collections\VariantCollection; use craft\shopify\db\Table; +use craft\shopify\helpers\Metafield as MetafieldHelper; use craft\shopify\Plugin; use craft\shopify\records\ShopifyData; use craft\shopify\tests\fixtures\ShopifyDataFixture; @@ -242,7 +243,7 @@ public function setImages(array $images): void public function setMetafields(array $metafields): void { - $this->metafields = $metafields; + $this->metafields = MetafieldHelper::normalizeToMap($metafields); } }; }, $shopifyGids); From 07f4688c4f53750e1006b4d589c2912edb790db7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 11:42:35 +0100 Subject: [PATCH 08/12] Tweak delete product --- src/services/Products.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/services/Products.php b/src/services/Products.php index 65a9bea3..d7283b22 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -192,21 +192,27 @@ public function normalizeShopifyGid(string $shopifyId, string $type = 'Product') * Deletes a product element by the Shopify GID. * * @param string $gid - * @return void + * @return bool Whether the deletion was performed. Returns false if `$gid` is empty. * @throws \Throwable * @throws StaleObjectException * @since 8.0.0 */ - public function deleteProductByShopifyGid(string $gid): void + public function deleteProductByShopifyGid(string $gid): bool { - if ($gid) { - if ($product = Product::find()->shopifyId($gid)->one()) { - // We hard delete because it will have been hard deleted in Shopify - Craft::$app->getElements()->deleteElement($product, true); - } + if (!$gid) { + return false; + } - $this->deleteShopifyDataByShopifyGid($this->normalizeShopifyGid($gid)); + $gid = $this->normalizeShopifyGid($gid); + + if ($product = Product::find()->shopifyGid($gid)->one()) { + // We hard delete because it will have been hard deleted in Shopify + Craft::$app->getElements()->deleteElement($product, true); } + + $this->deleteShopifyDataByShopifyGid($gid); + + return true; } /** From 5e25d3ce80207c335fcd37e7bb8448820cb40453 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 11:45:45 +0100 Subject: [PATCH 09/12] bump schema version --- src/Plugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugin.php b/src/Plugin.php index 744c00cd..9a6eee2b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -79,7 +79,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '7.1.0.0'; + public string $schemaVersion = '8.0.0'; /** * @inheritdoc From bee928a84b9b73b7296e7b33e273eaba7a675f71 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 14:01:24 +0100 Subject: [PATCH 10/12] Add default for little extra security --- src/jobs/ProcessBulkOperationData.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jobs/ProcessBulkOperationData.php b/src/jobs/ProcessBulkOperationData.php index 23655b9a..159d2fe9 100644 --- a/src/jobs/ProcessBulkOperationData.php +++ b/src/jobs/ProcessBulkOperationData.php @@ -23,7 +23,7 @@ class ProcessBulkOperationData extends BaseBatchedJob /** * @var string */ - public string $bulkOperationShopifyGid; + public string $bulkOperationShopifyGid = ''; /** * @var string From 7b583af0dfded9406e3f1146277bf591a4465fb9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 14:05:02 +0100 Subject: [PATCH 11/12] Tweak changeling Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG-WIP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index f6f2d415..888af6ee 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -11,8 +11,8 @@ - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. - `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. -- Deprecated `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId`. Use `$bulkOperationShopifyGid` instead. -- Deprecated `craft\shopify\models\BulkOperation::$shopifyId`. Use `$shopifyGid` instead. +- Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. +- Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. - Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead. From c6b73c2538d2390a075523ca8183d1e0a13cfec1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 14:05:33 +0100 Subject: [PATCH 12/12] Tweak deprecated method --- src/services/Products.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/Products.php b/src/services/Products.php index d7283b22..f89e2a53 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -224,6 +224,10 @@ public function deleteProductByShopifyGid(string $gid): bool */ public function deleteProductByShopifyId($id): void { + if (!$id) { + return; + } + $this->deleteProductByShopifyGid($id); }