diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aedb60c3..c5bf8a6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,8 @@ on: workflow_dispatch: push: branches: - - '7.x' + - '8.x' + - '8.0' pull_request: permissions: contents: read diff --git a/.github/workflows/craft4-ci.yml b/.github/workflows/craft4-ci.yml deleted file mode 100644 index f1f0bf41..00000000 --- a/.github/workflows/craft4-ci.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: craft4-ci -on: - workflow_dispatch: - push: - branches: - - '7.x' - pull_request: -permissions: - contents: read -concurrency: - group: craft4-ci-${{ github.ref }} - cancel-in-progress: true -jobs: - code-quality: - name: code-quality (Craft 4) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: composer:2.7 - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - - name: Install Craft 4 dependencies - run: composer update --no-interaction --no-audit --no-progress --prefer-dist --with craftcms/cms:^4 --with-all-dependencies - - name: Run ECS - run: composer check-cs - - name: Run PHPStan - run: vendor/bin/phpstan analyse --configuration=phpstan.craft4.neon --memory-limit=1G - - name: Install Node dependencies - run: npm ci - - name: Run Prettier - run: npm run check-prettier - - tests: - name: tests (Craft 4, ${{ matrix.db }}) - runs-on: ubuntu-latest - strategy: - matrix: - db: [mysql, pgsql] - include: - - db: mysql - env_file: tests/.env.example.mysql - - db: pgsql - env_file: tests/.env.example.pgsql - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: yes - MYSQL_DATABASE: craft_test - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - postgres: - image: postgres:16 - env: - POSTGRES_USER: root - POSTGRES_DB: craft_test - POSTGRES_HOST_AUTH_METHOD: trust - ports: - - 5432:5432 - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: composer:2.7 - - name: Install Craft 4 dependencies - run: composer update --no-interaction --no-audit --no-progress --prefer-dist --with craftcms/cms:^4 --with-all-dependencies - - name: Create test environment - run: cp ${{ matrix.env_file }} tests/.env - - name: Run Tests - run: composer run testunit diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000..31963a68 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,77 @@ +# WIP Release Notes for Shopify 8.0 + +> [!IMPORTANT] +> Ensure the Craft queue is empty before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. +> +> If you change the **Additional Features** or **Custom Scopes** settings after the app is already authorized, you must update the scopes in your Shopify app configuration and then re-authorize the app. + +This is primarily a maintenance release, focusing on Shopify API compatibility, authorization, and overall consistency. + +Developers should review their templates and extensions for potentially breaking changes to products’ and variants’ `shopifyId` property. +See [Upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgrading) for details. + +### Store Management + +- Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) +- It’s now possible to view the required API scopes in the plugin settings. +- It’s now possible to extend the API scopes with opt-in additional features and custom scopes. +- Added support for Shopify API versions `2026-04` and `2026-07`. +- Product inventory is now synced when Shopify sends `inventory_items/update` webhooks. + +### Extensibility + +- Added `craft\shopify\auth\OAuthFlow`. +- Added `craft\shopify\clients\GraphqlClient`. +- Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. +- Added `craft\shopify\enums\ApiVersion`. +- Added `craft\shopify\enums\WebhookTopics`. +- Added `craft\shopify\exceptions\InvalidOAuthException`. +- Added `craft\shopify\exceptions\ShopifyApiCommunicationException`. +- Added `craft\shopify\exceptions\ShopifyApiException`. +- Added `craft\shopify\helpers\Metafield`. +- Added `craft\shopify\helpers\ShopifyHelper`. +- Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. +- Added `craft\shopify\models\Settings::getAdditionalFeatures()`. +- Added `craft\shopify\models\Settings::getAdditionalFeaturesOptions()`. +- Added `craft\shopify\models\Settings::getCustomScopes()`. +- Added `craft\shopify\models\Settings::getScopes()`. +- Added `craft\shopify\models\Settings::setAdditionalFeatures()`. +- Added `craft\shopify\models\Settings::setCustomScopes()`. +- Added `craft\shopify\models\Variant::$shopifyGid`. +- Added `craft\shopify\services\Api::connect()`. +- Added `craft\shopify\services\Api::getShopLocalesGql()`. +- Added `craft\shopify\services\Api::getWebhookTopics()`. +- Added `craft\shopify\services\BulkOperations::getBulkOperationByShopifyGid()`. +- Added `craft\shopify\services\Products::deleteProductByShopifyGid()`. +- Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. +- Added `craft\shopify\services\Products::syncProductByShopifyGid()`. +- Added `craft\shopify\webhooks\WebhookRegistry`. +- `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. +- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. +- `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`. +- `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. +- API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. +- 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. +- Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead. +- Removed `craft\shopify\console\controllers\SyncController::$throttle`. +- Removed `craft\shopify\models\Settings::getApiKey()`. Use `getClientId()` instead. +- Removed `craft\shopify\models\Settings::getApiSecretKey()`. Use `getClientSecret()` instead. +- Removed `craft\shopify\models\Settings::setApiKey()`. Use `setClientId()` instead. +- Removed `craft\shopify\models\Settings::setApiSecretKey()`. Use `setClientSecret()` instead. +- Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. +- Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. +- Removed `craft\shopify\services\Api::initializeContext()`. + +### System + +- Shopify for Craft now requires Craft CMS 5.10.7 or later. Craft 4 is no longer supported. +- The `shopify_data` table’s `shopifyId` column has been renamed to `shopifyGid`. A new `shopifyId` generated column has been added, set to the numeric ID at the end of the GID. +- The `shopify_bulkoperations` table’s `shopifyId` column has been renamed to `shopifyGid`. +- Removed the `shopify/shopify-api` Composer dependency. +- Fixed a bug where validation errors for the “Context Pricing Countries” setting weren’t displaying correctly. +- Fixed a bug where `inventory_levels/update` webhooks weren’t triggering a product sync. diff --git a/README.md b/README.md index 6a8456bd..256054f4 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com) products into [Craft CMS](https://craftcms.com/). > [!IMPORTANT] -> Version 7.x of Shopify for Craft uses a new app-based authorization system. -> You must follow the [upgrade instructions](#upgrading) to get new credentials. +> Please review the [upgrade instructions](#upgrading) for some important changes. ## Topics @@ -18,7 +17,7 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com ## Installation -Shopify requires Craft CMS 4.15.0+ or 5.0.0+. +Shopify requires Craft CMS 5.10.7+. To install the plugin, visit the [Plugin Store](https://plugins.craftcms.com/shopify) from your Craft project, or follow these instructions. @@ -72,14 +71,15 @@ To install an app into a store, one of these statements must describe your accou ```bash SHOPIFY_WEBHOOK_VERSION="2026-01" ``` - - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - **Access** → **Scopes**: The following scopes are always required: - `read_inventory` - `read_product_listings` - `read_products` - - Shopify requires these to be in a comma-separated list: - ``` - read_inventory,read_product_listings,read_products - ``` + + If you plan to enable any **Additional Features** or **Custom Scopes** in the plugin settings, those will require additional scopes. Once the plugin is installed and configured, use the read-only **Scopes** field in **Shopify** → **Settings** as the source of truth: it always reflects the full, comma-separated string to paste here. + + > [!WARNING] + > If you later change your **Additional Features** or **Custom Scopes** settings, you must update the scopes in your Shopify app configuration and then re-authorize the app from the Craft control panel. - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. 1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. 1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: @@ -154,7 +154,8 @@ In this step, we’ll perform the [authorization code grant](https://shopify.dev > If you do not see a blue banner confirming **This app is exclusive to your store**, _do not proceed_! > A banner saying **This app can’t be installed on this store** (or landing on a generic Shopify error page) usually means that the hostname is not valid for the distribution. 1. You will be redirected to the Craft control panel “auth” URL you used when creating the Shopify app. (If you were not already logged in, Craft will ask for your username and password; your user must have the **Access Shopify** permission or be an administrator to complete the authorization flow.) -1. Press **Authorize** in the dialog. +1. Confirm the store’s hostname and press **Authorize** in the dialog: + ![Completing the OAuth flow in Craft](docs/shopify-authorize-cp.png) 1. Craft and Shopify will perform the OAuth handshake, and you should land on a confirmation screen in the Craft control panel saying **Your Shopify app has been successfully authorized**. 🎊 Congratulations! Your Craft project can now communicate with the Shopify API. @@ -208,7 +209,50 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho ## Upgrading -This version (7.x) is primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). +> While it is technically possible to upgrade directly from 6.x to the latest 8.x version, we strongly recommend reviewing the [6.x upgrade guide](#from-6x), as an intermediate step. + +### From 7.x + +> [!WARNING] +> Ensure the Craft queue is empty, before upgrading. +> Any pending [sync](#synchronization) jobs will be unable to update their status after the migration runs. +> In-progress bulk-synchronization operations (in Shopify) should be unaffected, unless you opt in to [additional features](#additional-features) during the upgrade. + +Shopify 8.0 requires **Craft CMS 5.10.7 or later**, and drops support for Craft 4.x. + +The most significant change for most developers will be our handling of Shopify IDs and GIDs. +`craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). +The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. +**Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string.** + +Examples in this document reflect this change; you should no longer need to to manipulate the GID string for add-to-cart forms or other situations that required the numeric ID. + +> [!WARNING] +> This also changes the plugin’s GraphQL API: querying a variant’s `shopifyId` field previously returned the full GID, and now returns the numeric ID only. +> Use the `shopifyGid` field if you need the full GID. +> If you have external clients or headless front-ends querying this plugin’s GraphQL API, audit them for this change. + +The following methods are deprecated in favor of GID-based equivalents. Update any direct calls: + +- `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()` → `getBulkOperationByShopifyGid()` +- `craft\shopify\services\Products::deleteProductByShopifyId()` → `deleteProductByShopifyGid()` +- `craft\shopify\services\Products::deleteShopifyDataByShopifyId()` → `deleteShopifyDataByShopifyGid()` +- `craft\shopify\services\Products::syncProductByShopifyId()` → `syncProductByShopifyGid()` + +> [!WARNING] +> The `shopify/shopify-api` package is no longer a dependency of this plugin. If any custom code references its classes directly—like `Shopify\Clients\Graphql`, `Shopify\Exception\ShopifyException`, `Shopify\Webhooks\Registry`, `Shopify\Auth\OAuth`, or `Shopify\Context`—update it to use the plugin’s own equivalents (`craft\shopify\clients\GraphqlClient`, `craft\shopify\exceptions\ShopifyApiException`, `craft\shopify\webhooks\WebhookRegistry`, `craft\shopify\auth\OAuthFlow`) instead. + +If you plan to enable any of the new [Additional Features](#additional-features) or the `customScopes` setting as part of this upgrade, see the scope re-authorization requirements described there—enabling them after the app is already authorized requires updating your Shopify app’s scopes and re-authorizing. + +> [!TIP] +> The [changelog](https://github.com/craftcms/shopify/blob/8.x/CHANGELOG.md) contains a full list of added, changed, and deprecated classes and methods. + +### From 6.x + +> These instructions were originally published with the release of 7.x, but we have adapted them here for convenience. +> You only need to follow these instructions if you are upgrading from 6.x directly to 8.x. + +Version 7.0 was primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. @@ -216,27 +260,29 @@ Due to significant shifts in Shopify’s developer ecosystem, many of the [front > We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. > The [changelog](https://github.com/craftcms/shopify/blob/7.x/CHANGELOG.md) contains specific information about the classes and methods that have been added, removed, or deprecated. -After the upgrade, you **must** [delete and re-create](#set-up-webhooks) webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. +After the upgrade, you **must** [delete and re-create webhooks](#set-up-webhooks) for each environment. +Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. -Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them! +Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. +While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them! -### Credentials +#### Credentials At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard). You should be able to [create a new app](#create-an-app), and [install it](#install-in-a-store) using the new OAuth mechanism, without disruption to product synchronization. -### Publishing and Status +#### Publishing and Status Shopify has eliminated [sales channels for custom apps](https://shopify.dev/docs/apps/build/sales-channels/start-building), and therefore the [`publishedOnCurrentPublication` field](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product#field-Product.fields.publishedOnCurrentChannel) is no longer available in Product queries. This means that there is no official way to “publish” products to the Craft integration, but we cover some alternatives in the [sales channel emulation](#emulate-sales-channels) section. -### Product Field Layouts +#### Product Field Layouts The product element editor has received a major overhaul. You can now choose exactly where Shopify data is placed, within the [field layout](#custom-fields). -### Front-End SDKs +#### Front-End SDKs Shopify has retired many of its pre-built client-side frameworks, in favor of directly communicating with the generic [Storefront GraphQL API](#storefront-api-client). You will need to revise how you query and mutate data, if your front-end currently depends on the JS Buy SDK or Buy Button JS. @@ -263,7 +309,7 @@ Going forward, your products are automatically kept in sync via [webhooks](#set- ### Native Attributes -In addition to the standard element attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product): +In addition to the standard [element](https://craftcms.com/docs/5.x/system/elements.html) attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product): | Attribute | Description | Type | |------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------| @@ -301,7 +347,7 @@ The product element has a few methods you might find useful in your [templates]( #### `Product::getVariants()` -Returns an array of [variants](#variants-and-pricing) belonging to the product. +Returns a collection of [variants](#variants-and-pricing) belonging to the product. Variants are _not_ elements (just regular models), but you can use the same dot notation to access their properties: ```twig @@ -309,11 +355,15 @@ Variants are _not_ elements (just regular models), but you can use the same dot ``` +> [!NOTICE] +> Like products, variants’ `id`s are Craft-specific identifiers. +> Use `shopifyGid` or `shopifyId` for the canonical Shopify values. + You can [eager-load](#eager-loading) variants alongside products using the [product query](#querying-products)’s `.withVariants()` method. #### `Product::getDefaultVariant()` @@ -466,9 +516,9 @@ Filter by legacy numeric Shopify product IDs. Filter by [Shopify GIDs](https://shopify.dev/docs/api/admin-graphql/2026-01/scalars/ID). ```twig -{# Watch out—these aren't the same as element IDs! #} +{# Watch out! These aren’t the same as element IDs or Shopify IDs. #} {% set singleProduct = craft.shopifyProducts - .shopifyId('gid://shopify/Product/123456789') + .shopifyGid('gid://shopify/Product/123456789') .one() %} ``` @@ -528,9 +578,9 @@ Tags are stored as a JSON array, which may complicate direct comparisons. You ma Options are stored as a JSON array, which may complicate direct comparisons. You may see better results using [the `.search()` param](https://craftcms.com/docs/5.x/system/searching.html#development). ```twig -{# Find products whose options include a `size` key: #} +{# Find products with an option value like "Large": #} {% set clogs = craft.shopifyProducts - .tags('*"size"*') + .search('*Large*') .all() %} ``` @@ -824,7 +874,7 @@ If you want to let customers pick from _options_ instead of directly select from id: 'variant', data: { variants: product.variants | map(v => { - gid: v.shopifyId, + gid: v.shopifyGid, selectedOptions: v.data.selectedOptions, }), }, @@ -951,18 +1001,6 @@ Your customers can add products to their cart directly from your Craft site by ` ``` -### JS Buy SDK - -The JS Buy SDK is no longer maintained, and is not compatible with the new APIs or authorization scheme. - -### Buy Button JS - -The above example can be simplified with the [Buy Button JS](https://shopify.dev/custom-storefronts/tools/buy-button), which provides some ready-made UI components, like a fully-featured cart. The principles are the same: - -1. Make products available via the appropriate sales channels in Shopify; -2. Output synchronized product data in your front-end; -3. Initialize, attach, or trigger SDK functionality in response to events, using Shopify-specific identifiers from step #2; - ### Storefront API Client > [!WARNING] @@ -988,7 +1026,7 @@ See the [usage examples](https://github.com/Shopify/shopify-app-js/tree/main/pac ```twig {% for variant in product.variants %} - + {% endfor %} ``` @@ -1220,21 +1258,64 @@ This section describes advanced ways to customize the plugin’s behavior. The following settings can also be set via a `shopify.php` file in your `config/` directory. -| Setting | Type | Default | Description | -|------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `apiKey` | `string` | — | Shopify API key. | -| `apiSecretKey` | `string` | — | Shopify API secret key. | -| `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. | -| `accessToken` | `string` | — | Shopify API access token. | -| `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. | -| `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. | -| `uriFormat` | `string` | — | Product element URI format. | -| `template` | `string` | — | Product element template path. | +| Setting | Type | Default | Description | +|------------------------------|------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `clientId` | `string` | — | Shopify API client ID. | +| `clientSecret` | `string` | — | Shopify API client secret key. | +| `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. | +| `accessToken` | `string` | — | Shopify API access token. | +| `additionalFeatures` | `string[]` | `[]` | Array of additional feature handles to enable (e.g. `['productTranslations']`). Enabling features may add required API scopes; see [Additional Features](#additional-features). | +| `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. | +| `customScopes` | `string` | — | Comma-separated list of additional API scopes to request beyond the plugin's [required scopes](#create-an-app). | +| `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. | +| `uriFormat` | `string` | — | Product element URI format. | +| `template` | `string` | — | Product element template path. | > [!NOTE] -> Setting `apiKey`, `apiSecretKey`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify). +> Setting `clientId`, `clientSecret`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify). > You can still reference environment values from the config file with `craft\helpers\App::env()`. +### Additional Features + +Additional features are opt-in capabilities that extend the plugin's default behavior. + +> [!WARNING] +> Enabling or disabling additional features may change the required API scopes! After saving the settings, you must update the **Access** → **Scopes** field in your Shopify app configuration and then re-authorize the app from the Craft control panel, _in each environment_. + +Features can also be enabled via `config/shopify.php`: + +```php +return [ + 'additionalFeatures' => ['productTranslations'], +]; +``` + +#### Product Translations + +**Handle:** `productTranslations` | **Required scope:** `read_locales` + +When enabled, the plugin fetches Shopify's published store locales during product sync and includes translation data for each non-primary locale in the product's raw data. This lets you surface translated product content (titles, descriptions, etc.). + +Translation data is stored in `product.getData()`, keyed by locale, like `translations_fr` for French. Each entry is an array of `key`/`value` pairs corresponding to Shopify's [translatable resource keys](https://shopify.dev/docs/api/admin-graphql/latest/objects/Translation). + +```twig +{# Loop over French translations for a product #} +{% set translations = product.getData()['translations_fr'] ?? [] %} +{% for translation in translations %} +

{{ translation.key }}: {{ translation.value }}

+{% endfor %} +``` + +To make this data easier to work with, consider indexing it by `key`: + +```twig +{% set translationsByKey = collect(product.getData()['translations_fr']) + .keyBy('key') + .mapWithKeys((item, k) => { (k): item.value }) %} + +{{ translationsByKey.title ?? product.title }} +``` + ### Emulate Sales Channels Private apps no longer come with a sales channel that allows merchants to selectively expose products to the Craft integration. @@ -1465,7 +1546,7 @@ query getProductPrice($id: ID!) { {# Execute the query #} {% set response = craft.shopify.api.query(priceQuery, { - id: product.shopifyId + id: product.shopifyGid }) %} {# Access the pricing data #} @@ -1485,7 +1566,7 @@ Key elements of this approach: - `contextualPricing(context: {country: XX})` returns market-specific prices (use the country code directly, e.g., `GB`, `US`, `DE`) - Use an alias like `gbPricing:` to name the result for easy access in Twig -- Pass the product's `shopifyId` (already a GID) directly to the query +- Pass the product's `shopifyGid` (already a GID) directly to the query - The `query()` method returns the first result directly, so access `response.variants` (not `response.data.product.variants`) - Falls back to the default `variant.price` if contextual pricing is not available diff --git a/composer.json b/composer.json index e84111da..35792c8b 100644 --- a/composer.json +++ b/composer.json @@ -22,14 +22,14 @@ "require": { "php": "^8.2", "carnage/php-graphql-client": "^1.14", - "craftcms/cms": "^5.0.0-beta.10||^4.15.0", - "shopify/shopify-api": "^6.0.0" + "craftcms/cms": "^5.10.7", + "guzzlehttp/guzzle": "^7.2" }, "require-dev": { "codeception/codeception": "^5.0.11", "codeception/module-asserts": "^3.0.0", "codeception/module-yii2": "^1.1.9", - "craftcms/feed-me": "^6.6.1||^5.9.0", + "craftcms/feed-me": "^6.6.1", "craftcms/ecs": "dev-main", "craftcms/phpstan": "dev-main", "craftcms/rector": "dev-main", diff --git a/composer.lock b/composer.lock index 83825d27..35c65c99 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c4bd3bcadf528b01b98cf14d2514119", + "content-hash": "8a28934dc4bbbf935e64de127ce7e47e", "packages": [ { "name": "bacon/bacon-qr-code", @@ -63,16 +63,16 @@ }, { "name": "brick/math", - "version": "0.17.0", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", - "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { @@ -110,7 +110,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.0" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -118,7 +118,7 @@ "type": "github" } ], - "time": "2026-03-17T12:54:54+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -538,16 +538,16 @@ }, { "name": "craftcms/cms", - "version": "5.10.5", + "version": "5.10.10", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "f781aecea946641c1c66baa5cf4a39872f99e7c6" + "reference": "d4c5f814e59de56a3124776da6b3c399cb28d64f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/f781aecea946641c1c66baa5cf4a39872f99e7c6", - "reference": "f781aecea946641c1c66baa5cf4a39872f99e7c6", + "url": "https://api.github.com/repos/craftcms/cms/zipball/d4c5f814e59de56a3124776da6b3c399cb28d64f", + "reference": "d4c5f814e59de56a3124776da6b3c399cb28d64f", "shasum": "" }, "require": { @@ -556,6 +556,7 @@ "composer/semver": "^3.3.2", "craftcms/plugin-installer": "~1.6.0", "craftcms/server-check": "~5.1.0", + "craftcms/url-validator": "^1.0", "creocoder/yii2-nested-sets": "~0.9.0", "elvanto/litemoji": "~4.3.0", "enshrined/svg-sanitize": "~0.22.0", @@ -597,7 +598,7 @@ "theiconic/name-parser": "^1.2", "twig/twig": "~3.27.0", "voku/portable-ascii": "^2.0", - "web-auth/webauthn-lib": "~5.2.4", + "web-auth/webauthn-lib": "~5.3.5", "yiisoft/yii2": "~2.0.55.0", "yiisoft/yii2-debug": "~2.1.27.0", "yiisoft/yii2-queue": "~2.3.2", @@ -663,7 +664,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-02T18:34:00+00:00" + "time": "2026-07-08T22:13:52+00:00" }, { "name": "craftcms/plugin-installer", @@ -760,6 +761,62 @@ }, "time": "2026-04-07T16:48:35+00:00" }, + { + "name": "craftcms/url-validator", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/craftcms/url-validator.git", + "reference": "3918c21317a856d6313b3fbf40d70ce013cdd715" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/url-validator/zipball/3918c21317a856d6313b3fbf40d70ce013cdd715", + "reference": "3918c21317a856d6313b3fbf40d70ce013cdd715", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.0.2" + }, + "require-dev": { + "laravel/pint": "^1.29", + "nunomaduro/collision": "^8.1", + "pestphp/pest": "^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CraftCms\\UrlValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "Validate URLs and IP addresses against SSRF, DNS rebinding, and cloud-metadata attacks.", + "homepage": "https://github.com/craftcms/url-validator", + "keywords": [ + "IP", + "craftcms", + "security", + "ssrf", + "url", + "validation" + ], + "support": { + "issues": "https://github.com/craftcms/url-validator/issues", + "source": "https://github.com/craftcms/url-validator/tree/1.1.0" + }, + "time": "2026-07-06T20:35:29+00:00" + }, { "name": "creocoder/yii2-nested-sets", "version": "0.9.0", @@ -1371,94 +1428,28 @@ }, "time": "2025-10-17T16:34:55+00:00" }, - { - "name": "firebase/php-jwt", - "version": "v7.1.0", - "source": { - "type": "git", - "url": "https://github.com/googleapis/php-jwt.git", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.4", - "phpfastcache/phpfastcache": "^9.2", - "phpseclib/phpseclib": "~3.0", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^2.0||^3.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", - "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firebase\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/googleapis/php-jwt", - "keywords": [ - "jwt", - "php" - ], - "support": { - "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" - }, - "time": "2026-06-11T17:54:14+00:00" - }, { "name": "guzzlehttp/guzzle", - "version": "7.11.1", + "version": "7.14.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" + "reference": "6b1d2429a2c312474c523aa9017fba0c07b5f4a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/6b1d2429a2c312474c523aa9017fba0c07b5f4a0", + "reference": "6b1d2429a2c312474c523aa9017fba0c07b5f4a0", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.11", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.5", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1466,8 +1457,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1547,7 +1538,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.1" + "source": "https://github.com/guzzle/guzzle/tree/7.14.1" }, "funding": [ { @@ -1563,20 +1554,20 @@ "type": "tidelift" } ], - "time": "2026-06-07T22:54:06+00:00" + "time": "2026-07-13T01:32:54+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1631,7 +1622,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1647,20 +1638,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.12.5", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9365d578a9fd1552ad6ca9c3cb530708526feb09", + "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09", "shasum": "" }, "require": { @@ -1669,7 +1660,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1750,7 +1741,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.5" }, "funding": [ { @@ -1766,7 +1757,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-07-13T01:27:20+00:00" }, { "name": "illuminate/collections", @@ -2403,16 +2394,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -2420,7 +2411,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -2464,9 +2455,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "mikehaertl/php-shellcommand", @@ -3060,16 +3051,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.8.0", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb" + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb", - "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "shasum": "" }, "require": { @@ -3091,7 +3082,7 @@ "maennchen/zipstream-php": "^2.1 || ^3.0", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": "^8.1", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { @@ -3105,7 +3096,7 @@ "phpstan/phpstan": "^1.1 || ^2.0", "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^10.5 || ^11.0", "squizlabs/php_codesniffer": "^3.7", "tecnickcom/tcpdf": "^6.5" }, @@ -3163,22 +3154,22 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" }, - "time": "2026-06-07T03:51:10+00:00" + "time": "2026-07-12T19:17:39+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -3210,9 +3201,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "pixelandtonic/graphql-php", @@ -3982,161 +3973,6 @@ }, "time": "2019-03-08T08:55:37+00:00" }, - { - "name": "ramsey/collection", - "version": "2.1.1", - "source": { - "type": "git", - "url": "https://github.com/ramsey/collection.git", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.45", - "fakerphp/faker": "^1.24", - "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^2.1", - "mockery/mockery": "^1.6", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpspec/prophecy-phpunit": "^2.3", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5", - "ramsey/coding-standard": "^2.3", - "ramsey/conventional-commits": "^1.6", - "roave/security-advisories": "dev-latest" - }, - "type": "library", - "extra": { - "captainhook": { - "force-install": true - }, - "ramsey/conventional-commits": { - "configFile": "conventional-commits.json" - } - }, - "autoload": { - "psr-4": { - "Ramsey\\Collection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ben Ramsey", - "email": "ben@benramsey.com", - "homepage": "https://benramsey.com" - } - ], - "description": "A PHP library for representing and manipulating collections.", - "keywords": [ - "array", - "collection", - "hash", - "map", - "queue", - "set" - ], - "support": { - "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.1" - }, - "time": "2025-03-22T05:38:12+00:00" - }, - { - "name": "ramsey/uuid", - "version": "4.x-dev", - "source": { - "type": "git", - "url": "https://github.com/ramsey/uuid.git", - "reference": "1a1f98b037d664d9093a620cfa85305c7e50b244" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1a1f98b037d664d9093a620cfa85305c7e50b244", - "reference": "1a1f98b037d664d9093a620cfa85305c7e50b244", - "shasum": "" - }, - "require": { - "brick/math": ">=0.8.16 <=0.17", - "php": "^8.0", - "ramsey/collection": "^1.2 || ^2.0" - }, - "replace": { - "rhumsaa/uuid": "self.version" - }, - "require-dev": { - "captainhook/captainhook": "^5.25", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "ergebnis/composer-normalize": "^2.47", - "mockery/mockery": "^1.6", - "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.6", - "php-mock/php-mock-mockery": "^1.5", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpbench/phpbench": "^1.2.14", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.18", - "squizlabs/php_codesniffer": "^3.13" - }, - "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." - }, - "default-branch": true, - "type": "library", - "extra": { - "captainhook": { - "force-install": true - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Ramsey\\Uuid\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", - "keywords": [ - "guid", - "identifier", - "uuid" - ], - "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.x" - }, - "time": "2026-04-27T22:11:13+00:00" - }, { "name": "samdark/yii2-psr-log-target", "version": "1.1.4", @@ -4254,80 +4090,6 @@ }, "time": "2020-12-15T21:32:01+00:00" }, - { - "name": "shopify/shopify-api", - "version": "v6.1.1", - "source": { - "type": "git", - "url": "https://github.com/Shopify/shopify-api-php.git", - "reference": "80e89587db4c6f111b52029a29a0f26277300b76" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/80e89587db4c6f111b52029a29a0f26277300b76", - "reference": "80e89587db4c6f111b52029a29a0f26277300b76", - "shasum": "" - }, - "require": { - "doctrine/inflector": "^2.0", - "ext-ctype": "*", - "ext-hash": "*", - "ext-json": "*", - "ext-mbstring": "*", - "firebase/php-jwt": "^7.0", - "guzzlehttp/guzzle": "^7.0", - "guzzlehttp/psr7": "^2.0", - "php": "^8.1", - "psr/http-client": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "psr/log": "^1.1 || ^2.0 || ^3.0", - "ramsey/uuid": "^4.1" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.30", - "ext-apcu": "*", - "maglnet/composer-require-checker": "^3.0 || ^4.0", - "mikey179/vfsstream": "^1.6", - "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.6" - }, - "suggest": { - "ext-apcu": "Log fewer API deprecation warnings" - }, - "type": "library", - "autoload": { - "psr-4": { - "Shopify\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Shopify Inc.", - "email": "dev-tools-education@shopify.com" - } - ], - "description": "Shopify API Library for PHP", - "keywords": [ - "Storefront API", - "admin api", - "app", - "graphql", - "jwt", - "node", - "rest", - "shopify", - "webhook" - ], - "support": { - "issues": "https://github.com/Shopify/shopify-api-php/issues", - "source": "https://github.com/Shopify/shopify-api-php/tree/v6.1.1" - }, - "time": "2026-03-02T21:44:12+00:00" - }, { "name": "spomky-labs/cbor-php", "version": "3.2.3", @@ -4658,16 +4420,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -4705,7 +4467,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4725,7 +4487,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/dom-crawler", @@ -4801,16 +4563,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.4.9", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { @@ -4862,7 +4624,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -4882,20 +4644,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -4942,7 +4704,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -4962,7 +4724,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -5036,16 +4798,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -5113,7 +4875,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.13" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -5133,20 +4895,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T09:57:54+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -5195,7 +4957,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -5215,20 +4977,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -5279,7 +5041,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.12" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -5299,7 +5061,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", @@ -6377,16 +6139,16 @@ }, { "name": "symfony/serializer", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd" + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/268c5aa6c4bd675eddd89348e7ecac292a843ddd", - "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "url": "https://api.github.com/repos/symfony/serializer/zipball/55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", "shasum": "" }, "require": { @@ -6457,7 +6219,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.10" + "source": "https://github.com/symfony/serializer/tree/v7.4.14" }, "funding": [ { @@ -6477,20 +6239,20 @@ "type": "tidelift" } ], - "time": "2026-05-03T13:03:28+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -6544,7 +6306,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -6564,7 +6326,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", @@ -6659,16 +6421,16 @@ }, { "name": "symfony/translation", - "version": "v6.4.38", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "afaa31b0c12d9a659eed1ea97f268a614cc1299c" + "reference": "fef99cef37890b350976f5f492854faefadd4e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/afaa31b0c12d9a659eed1ea97f268a614cc1299c", - "reference": "afaa31b0c12d9a659eed1ea97f268a614cc1299c", + "url": "https://api.github.com/repos/symfony/translation/zipball/fef99cef37890b350976f5f492854faefadd4e15", + "reference": "fef99cef37890b350976f5f492854faefadd4e15", "shasum": "" }, "require": { @@ -6734,7 +6496,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.38" + "source": "https://github.com/symfony/translation/tree/v6.4.42" }, "funding": [ { @@ -6754,20 +6516,20 @@ "type": "tidelift" } ], - "time": "2026-05-06T08:55:54+00:00" + "time": "2026-06-05T16:46:18+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -6816,7 +6578,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6836,7 +6598,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/type-info", @@ -7001,16 +6763,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -7064,7 +6826,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -7084,20 +6846,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", "shasum": "" }, "require": { @@ -7140,7 +6902,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.13" + "source": "https://github.com/symfony/yaml/tree/v7.4.14" }, "funding": [ { @@ -7160,7 +6922,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T06:06:12+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "theiconic/name-parser", @@ -7437,16 +7199,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.2.6", + "version": "5.3.5", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "0785f55f242c1cc026ec24a9c8653eac59fe3493" + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/0785f55f242c1cc026ec24a9c8653eac59fe3493", - "reference": "0785f55f242c1cc026ec24a9c8653eac59fe3493", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { @@ -7454,18 +7216,18 @@ "ext-openssl": "*", "paragonie/constant_time_encoding": "^2.6|^3.0", "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { @@ -7507,7 +7269,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.6" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { @@ -7519,20 +7281,20 @@ "type": "patreon" } ], - "time": "2026-03-23T22:13:50+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -7583,9 +7345,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "yiisoft/yii2", @@ -8164,16 +7926,16 @@ }, { "name": "cakephp/core", - "version": "5.3.6", + "version": "5.3.7", "source": { "type": "git", "url": "https://github.com/cakephp/core.git", - "reference": "9c458b0e9322ec88bc4c758b33cde6a0abf49d12" + "reference": "ffd32d2deab2076b31b18d8239cd6b8ed8c17a05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/core/zipball/9c458b0e9322ec88bc4c758b33cde6a0abf49d12", - "reference": "9c458b0e9322ec88bc4c758b33cde6a0abf49d12", + "url": "https://api.github.com/repos/cakephp/core/zipball/ffd32d2deab2076b31b18d8239cd6b8ed8c17a05", + "reference": "ffd32d2deab2076b31b18d8239cd6b8ed8c17a05", "shasum": "" }, "require": { @@ -8227,20 +7989,20 @@ "issues": "https://github.com/cakephp/cakephp/issues", "source": "https://github.com/cakephp/core" }, - "time": "2026-05-15T03:31:14+00:00" + "time": "2026-06-07T21:44:48+00:00" }, { "name": "cakephp/utility", - "version": "5.3.6", + "version": "5.3.7", "source": { "type": "git", "url": "https://github.com/cakephp/utility.git", - "reference": "4c703a010b9d955fed44731669e35d3043425cc7" + "reference": "0ce9d1fb015d3389d0170fc438f2a3b8374fe389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/utility/zipball/4c703a010b9d955fed44731669e35d3043425cc7", - "reference": "4c703a010b9d955fed44731669e35d3043425cc7", + "url": "https://api.github.com/repos/cakephp/utility/zipball/0ce9d1fb015d3389d0170fc438f2a3b8374fe389", + "reference": "0ce9d1fb015d3389d0170fc438f2a3b8374fe389", "shasum": "" }, "require": { @@ -8291,7 +8053,7 @@ "issues": "https://github.com/cakephp/cakephp/issues", "source": "https://github.com/cakephp/utility" }, - "time": "2026-05-21T19:38:13+00:00" + "time": "2026-06-07T21:44:48+00:00" }, { "name": "codeception/codeception", @@ -8476,16 +8238,16 @@ }, { "name": "codeception/lib-innerbrowser", - "version": "4.1.0", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/Codeception/lib-innerbrowser.git", - "reference": "8af7f8402f976b32f67a83dfd4e31f8f5b1f7db3" + "reference": "0fa80deaed7da6a92a0cd4117338394c69196ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/8af7f8402f976b32f67a83dfd4e31f8f5b1f7db3", - "reference": "8af7f8402f976b32f67a83dfd4e31f8f5b1f7db3", + "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/0fa80deaed7da6a92a0cd4117338394c69196ec6", + "reference": "0fa80deaed7da6a92a0cd4117338394c69196ec6", "shasum": "" }, "require": { @@ -8529,9 +8291,9 @@ ], "support": { "issues": "https://github.com/Codeception/lib-innerbrowser/issues", - "source": "https://github.com/Codeception/lib-innerbrowser/tree/4.1.0" + "source": "https://github.com/Codeception/lib-innerbrowser/tree/4.1.1" }, - "time": "2026-02-07T10:09:13+00:00" + "time": "2026-06-26T22:06:27+00:00" }, { "name": "codeception/lib-web", @@ -8780,21 +8542,22 @@ }, { "name": "craftcms/feed-me", - "version": "6.13.0.1", + "version": "6.14.0", "source": { "type": "git", "url": "https://github.com/craftcms/feed-me.git", - "reference": "9a2e37bb27045e17dfa91e6ef67370b19e06fbed" + "reference": "8099a337342ebab0f46e0dbf23a544cb2efcb017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/feed-me/zipball/9a2e37bb27045e17dfa91e6ef67370b19e06fbed", - "reference": "9a2e37bb27045e17dfa91e6ef67370b19e06fbed", + "url": "https://api.github.com/repos/craftcms/feed-me/zipball/8099a337342ebab0f46e0dbf23a544cb2efcb017", + "reference": "8099a337342ebab0f46e0dbf23a544cb2efcb017", "shasum": "" }, "require": { "cakephp/utility": "^5.0.0", - "craftcms/cms": "^5.8.0", + "craftcms/cms": "^5.10.9", + "craftcms/url-validator": "^1.0", "jakeasmith/http_build_url": "^1.0", "league/csv": "^9.0", "nesbot/carbon": "^2.10|^3.0.0", @@ -8846,7 +8609,7 @@ "rss": "https://github.com/craftcms/feed-me/commits/master.atom", "source": "https://github.com/craftcms/feed-me" }, - "time": "2026-05-28T18:56:10+00:00" + "time": "2026-07-08T22:22:40+00:00" }, { "name": "craftcms/html-field", @@ -9454,20 +9217,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -9506,9 +9268,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -10105,24 +9867,24 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -10187,44 +9949,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "psy/psysh", - "version": "v0.12.23", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -10288,9 +10034,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-05-23T13:41:31+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "rector/rector", @@ -11455,16 +11201,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bb28e8761a6c33975972948010f00d4a10f0a634", + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634", "shasum": "" }, "require": { @@ -11504,7 +11250,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.14" }, "funding": [ { @@ -11524,20 +11270,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -11602,7 +11348,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -11622,20 +11368,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -11670,7 +11416,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -11690,7 +11436,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symplify/easy-coding-standard", @@ -11800,16 +11546,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -11868,7 +11614,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -11880,7 +11626,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" } ], "aliases": [], diff --git a/docs/shopify-authorize-cp.png b/docs/shopify-authorize-cp.png new file mode 100644 index 00000000..cac5abb2 Binary files /dev/null and b/docs/shopify-authorize-cp.png differ diff --git a/phpstan.craft4.neon b/phpstan.craft4.neon deleted file mode 100644 index 030a133f..00000000 --- a/phpstan.craft4.neon +++ /dev/null @@ -1,26 +0,0 @@ -includes: - - phpstan.neon - -parameters: - # This config is only for the Craft 4 CI run. - # These ignores cover Craft 4 compatibility shims where PHPStan analyzes against - # a single runtime API surface and can report false positives for Craft 5-only APIs. - reportUnmatchedIgnoredErrors: false - excludePaths: - analyse: - - src/linktypes/Product.php - ignoreErrors: - - - message: '#Call to an undefined static method craft\\base\\Element::attributeHtml\(\)\.#' - path: src/elements/Product.php - - - message: '#Call to an undefined method craft\\web\\Response::noticeHtml\(\)\.#' - path: src/controllers/SettingsController.php - - - message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::conditionRuleTypes\(\)\.#' - path: src/elements/conditions/products/ProductCondition.php - - - message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::selectableConditionRules\(\)\.#' - path: src/elements/conditions/products/ProductCondition.php - - diff --git a/phpstan.neon b/phpstan.neon index b5576300..44554e4f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,8 +5,3 @@ parameters: level: 5 paths: - src - # Remove this ignore once Craft 4 support is dropped. - ignoreErrors: - - - message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::conditionRuleTypes\(\)\.#' - path: src/elements/conditions/products/ProductCondition.php diff --git a/src/Plugin.php b/src/Plugin.php index 9e186b4f..81122bdd 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -54,10 +54,10 @@ use craft\shopify\services\Store; use craft\shopify\utilities\Sync; use craft\shopify\web\twig\CraftVariableBehavior; +use craft\shopify\webhooks\WebhookRegistry; use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; use GraphQL\Query as GqlQuery; -use Shopify\Webhooks\Registry; use yii\base\Event; use yii\base\InvalidConfigException; @@ -79,7 +79,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '7.1.0.0'; + public string $schemaVersion = '8.0.0'; /** * @inheritdoc @@ -163,8 +163,8 @@ public function init() ->onRemove(self::PC_PATH_PRODUCT_FIELD_LAYOUTS, [$productsService, 'handleDeletedFieldLayout']); // Globally register shopify webhooks registry event handlers - foreach ($this->getApi()::WEBHOOK_TOPICS as $topic) { - Registry::addHandler($topic, new Webhook()); + foreach ($this->getApi()->getWebhookTopics() as $topic) { + WebhookRegistry::addHandler($topic, new Webhook()); } } @@ -388,8 +388,7 @@ public function _registerResaveCommands(): void private function _registerCpRoutes(): void { Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES, function(RegisterUrlRulesEvent $event) { - $session = Plugin::getInstance()->getApi()->getSession(); - $event->rules['shopify'] = ['template' => 'shopify/_index', 'variables' => ['hasSession' => (bool)$session]]; + $event->rules['shopify'] = ['template' => 'shopify/_index', 'variables' => ['hasSession' => Plugin::getInstance()->getApi()->connect()]]; $event->rules['shopify/products'] = 'shopify/products/product-index'; $event->rules['shopify/sync-products'] = 'shopify/products/sync'; @@ -450,8 +449,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'); @@ -525,7 +524,7 @@ public function getCpNavItem(): ?array $ret = parent::getCpNavItem(); $ret['label'] = Craft::t('shopify', 'Shopify'); - $session = Plugin::getInstance()->getApi()->getSession(); + $connected = Plugin::getInstance()->getApi()->connect(); $ret['subnav']['products'] = [ 'label' => Craft::t('shopify', 'Products'), @@ -537,7 +536,7 @@ public function getCpNavItem(): ?array 'url' => 'shopify/settings', ]; - if ($session) { + if ($connected) { if (Craft::$app->getUser()->getIsAdmin()) { $ret['subnav']['webhooks'] = [ 'label' => Craft::t('shopify', 'Webhooks'), diff --git a/src/auth/OAuthFlow.php b/src/auth/OAuthFlow.php new file mode 100644 index 00000000..4d5dbc05 --- /dev/null +++ b/src/auth/OAuthFlow.php @@ -0,0 +1,59 @@ + + * @since 8.0.0 + */ +class OAuthFlow +{ + public const STATE_COOKIE_NAME = 'shopify_app_state'; + public const STATE_SIG_COOKIE_NAME = 'shopify_app_state_sig'; + public const ACCESS_TOKEN_POST_PATH = '/admin/oauth/access_token'; + + /** + * Begins the OAuth authorization flow. + * + * Sets two signed state cookies (via $setCookieFunction) and returns the Shopify authorization URL. + * + * @param callable $setCookieFunction fn(string $name, string $value, int $expire): bool + */ + public static function begin( + string $hostName, + bool $isOnline, + string $clientId, + string $scopes, + string $clientSecret, + callable $setCookieFunction, + ): string { + $state = StringHelper::UUID(); + $sig = hash_hmac('sha256', $state, $clientSecret); + $expire = time() + 60; + + $setCookieFunction(self::STATE_COOKIE_NAME, $state, $expire); + $setCookieFunction(self::STATE_SIG_COOKIE_NAME, $sig, $expire); + + $redirectUri = Plugin::getInstance()->getSettings()->getAuthUrl(); + + $query = http_build_query([ + 'client_id' => $clientId, + 'scope' => $scopes, + 'redirect_uri' => $redirectUri, + 'state' => $state, + 'grant_options' => $isOnline ? ['per-user'] : [], + ]); + + return "https://{$hostName}/admin/oauth/authorize?{$query}"; + } +} diff --git a/src/clients/GraphqlClient.php b/src/clients/GraphqlClient.php new file mode 100644 index 00000000..0c1d7b11 --- /dev/null +++ b/src/clients/GraphqlClient.php @@ -0,0 +1,65 @@ + + * @since 8.0.0 + */ +class GraphqlClient +{ + private Client $_client; + + public function __construct( + string $shop, + string $accessToken, + private string $apiVersion, + ) { + $this->_client = Craft::createGuzzleClient([ + 'base_uri' => "https://{$shop}", + 'headers' => [ + 'X-Shopify-Access-Token' => $accessToken, + 'Content-Type' => 'application/json', + 'X-Shopify-Api-Features' => 'include-presentment-prices', + ], + ]); + } + + /** + * Executes a GraphQL query against the Shopify Admin API. + * + * @param array $data An array with at minimum a `query` key, optionally `variables`. + * @return array The decoded response body. + * @throws ShopifyApiCommunicationException on HTTP or communication failure. + */ + public function query(array $data, array $extraHeaders = []): array + { + try { + $options = ['json' => $data]; + if ($extraHeaders) { + $options['headers'] = $extraHeaders; + } + + $response = $this->_client->post( + "admin/api/{$this->apiVersion}/graphql.json", + $options, + ); + + return json_decode((string)$response->getBody(), true) ?? []; + } catch (GuzzleException $e) { + throw new ShopifyApiCommunicationException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php index a489f649..2276b16d 100644 --- a/src/collections/VariantCollection.php +++ b/src/collections/VariantCollection.php @@ -37,11 +37,12 @@ public static function make($items = []) continue; } elseif (is_array($item)) { $item += ['class' => Variant::class]; - $item = \Craft::createObject($item); + $item = Craft::createObject($item); } elseif ($item instanceof ShopifyData) { $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/console/controllers/ApiController.php b/src/console/controllers/ApiController.php index e42adb8e..7572505e 100644 --- a/src/console/controllers/ApiController.php +++ b/src/console/controllers/ApiController.php @@ -10,8 +10,8 @@ use Craft; use craft\console\Controller; use craft\helpers\Console; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; -use Shopify\Exception\ShopifyException; use yii\console\ExitCode; /** @@ -43,7 +43,7 @@ public function actionQuery(string $gql): int $this->stdout("Running query... "); $data = Plugin::getInstance()->getApi()->query($gql); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { $err = $e->getMessage(); } diff --git a/src/console/controllers/SyncController.php b/src/console/controllers/SyncController.php index 277d349d..19b9e2e4 100644 --- a/src/console/controllers/SyncController.php +++ b/src/console/controllers/SyncController.php @@ -23,32 +23,6 @@ class SyncController extends Controller /** @var string $defaultAction */ public $defaultAction = 'all'; - /** - * @var bool Whether to slow down API requests to avoid rate limiting. - * @since 5.2.0 - * @deprecated 7.0.0 - */ - public bool $throttle = false; - - /** - * @inheritdoc - */ - public function options($actionID): array - { - $options = parent::options($actionID); - $options[] = 'throttle'; - return $options; - } - - public function beforeAction($action): bool - { - if ($this->throttle) { - $this->stdout('The --throttle option has been deprecated and has no effect, as we fetch product data in bulk (and therefore are not subject to API rate limiting).' . PHP_EOL, Console::FG_YELLOW); - } - - return parent::beforeAction($action); - } - /** * Sync all Shopify data. */ diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index a0bb5fbc..2fc46c27 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -9,14 +9,12 @@ use Craft; use craft\helpers\Html; +use craft\shopify\auth\OAuthFlow; +use craft\shopify\exceptions\InvalidOAuthException; +use craft\shopify\helpers\ShopifyHelper; use craft\shopify\Plugin; use craft\web\Controller; use craft\web\Response; -use Shopify\Auth\OAuth; -use Shopify\Auth\OAuthCookie; -use Shopify\Context; -use Shopify\Exception\InvalidOAuthException; -use Shopify\Utils; use yii\web\Cookie; use yii\web\Response as YiiResponse; @@ -49,16 +47,15 @@ public function beforeAction($action): bool */ public function actionIndex(): YiiResponse { - Plugin::getInstance()->getApi()->initializeContext(); $settings = Plugin::getInstance()->getSettings(); $screen = $this->asCpScreen() ->title(Craft::t('shopify', 'Authorization')); - $validHmac = Utils::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); + $validHmac = ShopifyHelper::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); if (!$validHmac) { $html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), Craft::t('shopify', 'Invalid or missing HMAC. Please try re-installing the app.')); - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } // If a code is present, it means the user has been redirected back from Shopify after authorizing the app. @@ -66,7 +63,7 @@ public function actionIndex(): YiiResponse if ($code) { $cookies = Craft::$app->getRequest()->getCookies()->toArray(); foreach ($cookies as $name => $cookie) { - if (!in_array($name, [OAuth::STATE_COOKIE_NAME, OAuth::STATE_SIG_COOKIE_NAME]) || !$cookie instanceof Cookie) { + if (!in_array($name, [OAuthFlow::STATE_COOKIE_NAME, OAuthFlow::STATE_SIG_COOKIE_NAME]) || !$cookie instanceof Cookie) { continue; } @@ -74,7 +71,7 @@ public function actionIndex(): YiiResponse } try { - $accessToken = $this->_fetchAccessToken($cookies, Craft::$app->getRequest()->getQueryParams(), fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); + $accessToken = $this->_fetchAccessToken($cookies, Craft::$app->getRequest()->getQueryParams(), fn(string $n, string $v, int $e) => $this->_setCookies($n, $v, $e, $screen)); if (!$accessToken) { throw new InvalidOAuthException('Failed to retrieve access token.'); @@ -93,20 +90,26 @@ public function actionIndex(): YiiResponse Html::endTag('div') ; - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } catch (\Exception $e) { Craft::error($e->getMessage(), __METHOD__); $html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), $e->getMessage()); - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } } // If no code is present, it means the user is initiating the authorization process. - $path = Plugin::getInstance()->getSettings()->getAuthPath(); $shop = Craft::$app->getRequest()->getQueryParam('shop'); - $authorizeUrl = OAuth::begin($settings->getHostName(), $path, false, fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); + $authorizeUrl = OAuthFlow::begin( + $settings->getHostName(), + false, + $settings->getClientId(), + $settings->getScopes(), + $settings->getClientSecret(), + fn(string $n, string $v, int $e) => $this->_setCookies($n, $v, $e, $screen), + ); $html = Html::beginTag('div', ['class' => 'flex flex-justify-center']) . Html::beginTag('div', ['class' => 'pane centeralign', 'style' => 'max-width: 400px']) . @@ -118,18 +121,9 @@ public function actionIndex(): YiiResponse Html::endTag('div') . Html::endTag('div'); - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } - /** - * Render CP screen content across Craft 4/5. - * @TODO remove when the plugin no longer supports Craft 4 - */ - private function _screenContent(Response $screen, string $html): YiiResponse - { - $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml'; - return $screen->{$method}($html); - } /** * @param array $cookies @@ -137,22 +131,15 @@ private function _screenContent(Response $screen, string $html): YiiResponse * @param callable|null $setCookieFunction * @return string|null * @throws InvalidOAuthException - * @throws \Shopify\Exception\PrivateAppException - * @throws \Shopify\Exception\UninitializedContextException - * @throws \yii\base\InvalidConfigException */ private function _fetchAccessToken(array $cookies, array $query, ?callable $setCookieFunction = null): ?string { - Context::throwIfUninitialized(); - Context::throwIfPrivateApp('OAuth is not allowed for private apps'); - - // `getCookie()` - $signature = $cookies[OAuth::STATE_SIG_COOKIE_NAME] ?? null; - $cookieId = $cookies[OAuth::STATE_COOKIE_NAME] ?? null; + $signature = $cookies[OAuthFlow::STATE_SIG_COOKIE_NAME] ?? null; + $cookieId = $cookies[OAuthFlow::STATE_COOKIE_NAME] ?? null; $cookieState = null; if ($signature && $cookieId) { - $expectedSignature = hash_hmac('sha256', (string) $cookieId, Context::$API_SECRET_KEY); + $expectedSignature = hash_hmac('sha256', (string)$cookieId, Plugin::getInstance()->getSettings()->getClientSecret()); if ($signature === $expectedSignature) { $cookieState = $cookieId; @@ -163,7 +150,7 @@ private function _fetchAccessToken(array $cookies, array $query, ?callable $setC throw new InvalidOAuthException('Invalid OAuth callback.'); } - $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); + $sanitizedShop = ShopifyHelper::sanitizeShopDomain($query['shop'] ?? ''); return Plugin::getInstance()->getApi()->getAccessToken($query['code'], $sanitizedShop, true); } @@ -172,32 +159,29 @@ private function _fetchAccessToken(array $cookies, array $query, ?callable $setC * @param string|null $stateCookie * @return bool */ - private static function _isCallbackQueryValid(array $query, string | null $stateCookie): bool + private static function _isCallbackQueryValid(array $query, string|null $stateCookie): bool { - $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); + $sanitizedShop = ShopifyHelper::sanitizeShopDomain($query['shop'] ?? ''); $state = $query['state'] ?? ''; $code = $query['code'] ?? ''; return ( ($code) && ($sanitizedShop) && - ($state && $stateCookie && strcmp($stateCookie, (string) $state) === 0) && - Utils::validateHmac($query, Context::$API_SECRET_KEY) + ($state && $stateCookie && strcmp($stateCookie, (string)$state) === 0) && + ShopifyHelper::validateHmac($query, Plugin::getInstance()->getSettings()->getClientSecret()) ); } /** - * @param OAuthCookie $oauthCookie - * @param Response $screen - * @return bool * @throws \yii\base\InvalidConfigException */ - private function _setCookies(OAuthCookie $oauthCookie, Response $screen): bool + private function _setCookies(string $name, string $value, int $expire, Response $screen): bool { $cookieConfig = Craft::cookieConfig([ - 'name' => $oauthCookie->getName(), - 'value' => $oauthCookie->getValue(), - 'expire' => $oauthCookie->getExpire(), + 'name' => $name, + 'value' => $value, + 'expire' => $expire, ]); $cookie = Craft::createObject(array_merge($cookieConfig, ['class' => Cookie::class])); diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index c4d72d42..a2de28c7 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -16,7 +16,6 @@ use craft\shopify\models\Settings; use craft\shopify\Plugin; use craft\web\Controller; -use craft\web\Response as CraftResponse; use yii\web\Response; /** @@ -63,7 +62,17 @@ public function actionIndex(?Settings $settings = null): Response 'name' => 'settings[authUrl]', 'value' => $settings->getAuthUrl(), 'readonly' => true, - 'warning' => !Plugin::getInstance()->getApi()->getSession() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null, + 'warning' => !Plugin::getInstance()->getApi()->connect() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null, + ]; + + $scopesFieldConfig = [ + 'label' => $settings->getAttributeLabel('scopes'), + 'instructions' => Craft::t('shopify', 'API scopes required for your app integration, including additional features and custom scopes.'), + 'id' => 'scopes', + 'name' => 'settings[scopes]', + 'value' => $settings->getScopes(), + 'readonly' => true, + 'tip' => Craft::t('shopify', 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.'), ]; $html = Html::beginTag('div', ['id' => 'products', 'class' => 'hidden']) . @@ -173,6 +182,36 @@ public function actionIndex(?Settings $settings = null): Response Html::tag('hr') . + Html::beginTag('div', ['id' => 'scopes-settings']) . + + Cp::fieldHtml( + Cp::renderTemplate('_includes/forms/copytext.twig', $scopesFieldConfig), + $scopesFieldConfig + ) . + + Cp::checkboxSelectFieldHtml([ + 'id' => 'additionalFeatures', + 'label' => $settings->getAttributeLabel('additionalFeatures'), + 'name' => 'settings[additionalFeatures]', + 'options' => $settings->getAdditionalFeaturesOptions(), + 'values' => $settings->getAdditionalFeatures(), + 'showAllOption' => true, + ]) . + + Cp::autosuggestFieldHtml([ + 'label' => $settings->getAttributeLabel('customScopes'), + 'instructions' => Craft::t('shopify', 'A comma separated list of custom scopes to add to the API requests.'), + 'id' => 'customScopes', + 'name' => 'settings[customScopes]', + 'value' => $settings->getCustomScopes(false), + 'errors' => $settings->getErrors('customScopes'), + 'suggestEnvVars' => true, + ]) . + + Html::endTag('div') . + + Html::tag('hr') . + Cp::fieldHtml( Cp::renderTemplate('_includes/forms/copytext.twig', $authUrlFieldConfig), $authUrlFieldConfig @@ -181,6 +220,56 @@ public function actionIndex(?Settings $settings = null): Response Html::endTag('div') ; + $getScopesAction = 'shopify/settings/get-scopes'; + $js = << { + const scopesSettingsContainer = document.getElementById('scopes-settings'); + if (!scopesSettingsContainer) return; + + let debounceTimer; + + const updateScopes = () => { + const additionalFeatures = Array.from( + scopesSettingsContainer.querySelectorAll('input[name="settings[additionalFeatures][]"]:checked') + ).map(cb => cb.value).filter(Boolean); + + const customScopesInput = document.getElementById('customScopes'); + const scopesInput = document.getElementById('scopes'); + if (!scopesInput) return; + + Craft.sendActionRequest('POST', '$getScopesAction', { + data: { + additionalFeatures, + customScopes: customScopesInput?.value ?? '', + }, + }).then(response => { + scopesInput.value = response.data.scopes; + }).catch(() => { + Craft.cp.displayError(Craft.t('shopify', 'Couldn’t update scopes.')); + }); + }; + + scopesSettingsContainer.addEventListener('change', (e) => { + if (e.target.name === 'settings[additionalFeatures][]' || e.target.name === 'settings[additionalFeatures]') { + // Defer so Craft's checkbox-select JS can toggle related checkboxes first + setTimeout(updateScopes, 0); + } + }); + + scopesSettingsContainer.addEventListener('input', (e) => { + if (e.target.id === 'customScopes') { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(updateScopes, 300); + } + }); + })(); + JS; + $this->getView()->registerJs($js); + + $this->getView()->registerTranslations('shopify', [ + 'Couldn’t update scopes.', + ]); + $screen = $this->asCpScreen() ->title(Craft::t('shopify', 'Settings')) ->tabs([ @@ -193,23 +282,31 @@ public function actionIndex(?Settings $settings = null): Response $screen->action('shopify/settings/save-settings') ->redirectUrl('shopify/settings'); } else { - // @TODO remove when the plugin no longer support Craft 4 - if ($screen->hasMethod('noticeHtml') && method_exists(Cp::class, 'readOnlyNoticeHtml')) { - $screen->noticeHtml(Cp::readOnlyNoticeHtml()); - } + $screen->noticeHtml(Cp::readOnlyNoticeHtml()); } - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } /** - * Render CP screen content across Craft 4/5. - * @TODO remove when the plugin no longer supports Craft 4 + * Returns the combined scopes string for the given additional features and custom scopes. + * + * @return Response + * @since 7.2.0 */ - private function _screenContent(CraftResponse $screen, string $html): Response + public function actionGetScopes(): Response { - $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml'; - return $screen->{$method}($html); + $this->requireAcceptsJson(); + $this->requirePostRequest(); + + $request = Craft::$app->getRequest(); + $settings = new Settings(); + $settings->setAdditionalFeatures((array)$request->getBodyParam('additionalFeatures', [])); + $settings->setCustomScopes($request->getBodyParam('customScopes', '')); + + return $this->asJson([ + 'scopes' => $settings->getScopes(), + ]); } /** @@ -227,6 +324,13 @@ public function actionSaveSettings(): ?Response $pluginSettings = $plugin->getSettings(); $originalUriFormat = $pluginSettings->uriFormat; + $settings['additionalFeatures'] = $settings['additionalFeatures'] ?: []; + + // Expand the checkboxSelect "All" wildcard to the full list of feature handles + if ($settings['additionalFeatures'] === '*') { + $settings['additionalFeatures'] = array_keys($pluginSettings->getAdditionalFeaturesOptions()); + } + // Remove from editable table namespace $settings['uriFormat'] = $settings['routing']['uriFormat']; // Could be blank if in headless mode diff --git a/src/controllers/WebhookController.php b/src/controllers/WebhookController.php index eddf2260..2fd8445b 100644 --- a/src/controllers/WebhookController.php +++ b/src/controllers/WebhookController.php @@ -9,10 +9,11 @@ use Craft; use craft\shopify\Plugin; +use craft\shopify\webhooks\WebhookRegistry; use craft\web\Controller; -use Shopify\Webhooks\Registry; use yii\web\MethodNotAllowedHttpException; use yii\web\Response as YiiResponse; +use yii\web\ServerErrorHttpException; /** * The WebhookController handles the Shopify webhook request. @@ -30,23 +31,26 @@ class WebhookController extends Controller * Handles the webhooks from Shopify for all topics * * @return YiiResponse + * @throws MethodNotAllowedHttpException if no Shopify API session is available + * @throws ServerErrorHttpException if the webhook could not be processed (e.g. HMAC failure, unknown topic, or a handler error) */ public function actionHandle(): YiiResponse { $request = Craft::$app->getRequest(); - if (!Plugin::getInstance()->getApi()->getSession()) { + if (!Plugin::getInstance()->getApi()->connect()) { throw new MethodNotAllowedHttpException('No Shopify API session found, check credentials in settings.'); } try { - $response = Registry::process($request->headers->toArray(), $request->getRawBody()); - - if (!$response->isSuccess()) { - Craft::error("Webhook handler failed with message:" . $response->getErrorMessage()); - } + WebhookRegistry::process( + $request->headers->toArray(), + $request->getRawBody(), + Plugin::getInstance()->getSettings()->getClientSecret(), + ); } catch (\Exception $error) { Craft::error($error->getMessage()); + throw new ServerErrorHttpException('Could not process Shopify webhook. Check the logs for more information.', 0, $error); } $this->response->setStatusCode(200); diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index f7bb5df0..f96cd0a3 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -9,12 +9,11 @@ use Craft; use craft\helpers\Html; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; use craft\web\Controller; -use craft\web\Response as CraftResponse; use GraphQL\Query; use GraphQL\Variable; -use Shopify\Exception\ShopifyException; use yii\web\ConflictHttpException; use yii\web\Response as YiiResponse; @@ -48,16 +47,18 @@ public function beforeAction($action): bool */ public function actionEdit(): YiiResponse { - $view = $this->getView(); $api = Plugin::getInstance()->getApi(); try { $webhooks = $api->getWebhooks(); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { throw new ConflictHttpException('There was an issue connecting to the Shopify API. Please check your credentials.'); } - $requiredTopics = array_flip($api::WEBHOOK_TOPICS); + $requiredTopics = array_flip(array_map( + fn($t) => $t->toGraphQLEnum(), + $api->getWebhookTopics(), + )); foreach ($webhooks as $hook) { // When we discover a new topic, yank from the “required” array: @@ -164,17 +165,7 @@ public function actionEdit(): YiiResponse ->title(Craft::t('shopify', 'Webhooks')) ->selectedSubnavItem('webhooks'); - return $this->_screenContent($screen, $html); - } - - /** - * Render CP screen content across Craft 4/5 - * @TODO remove when the plugin no longer supports Craft 4 - */ - private function _screenContent(CraftResponse $screen, string $html): YiiResponse - { - $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml'; - return $screen->{$method}($html); + return $screen->contentHtml($html); } /** @@ -189,16 +180,16 @@ public function actionCreate(): ?YiiResponse try { $webhooks = $api->getWebhooks(); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { throw new ConflictHttpException('There was an issue connecting to the Shopify API. Please check your credentials.'); } $errors = []; // Check each required topic and create missing subscriptions: - foreach ($api::WEBHOOK_TOPICS as $topic) { + foreach ($api->getWebhookTopics() as $topic) { // Is there at least one webhook with this topic? - if ($webhooks->contains('topic', $topic)) { + if ($webhooks->contains('topic', $topic->toGraphQLEnum())) { continue; } @@ -229,7 +220,7 @@ public function actionCreate(): ?YiiResponse ]); $variables = [ - 'topic' => $topic, + 'topic' => $topic->toGraphQLEnum(), 'webhookSubscription' => [ 'format' => 'JSON', 'uri' => Plugin::getInstance()->getSettings()->getWebhookUrl(), @@ -239,7 +230,7 @@ public function actionCreate(): ?YiiResponse try { // Fire it off; if anything goes wrong, we’ll just catch + log it. $api->query($query, $variables); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { Craft::error('Could not register webhooks with Shopify API: ' . $e->getMessage(), __METHOD__); $errors[] = $e->getMessage(); } @@ -264,7 +255,7 @@ public function actionDelete(): YiiResponse try { Plugin::getInstance()->getApi()->deleteWebhookById($id); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { return $this->asFailure(Craft::t('shopify', 'Webhook could not be deleted')); } diff --git a/src/elements/Product.php b/src/elements/Product.php index 6c494a30..c3cb9d4a 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 ?? []; } @@ -731,7 +730,7 @@ public function getSidebarHtml(bool $static): string // Conditionally show metadata in the sidebar dependent on the field layout $excludeKeys = []; $fieldLayout = $this->getFieldLayout(); - $checkField = function($field) use (&$excludeKeys) { + $fieldLayout->getFields(function($field) use (&$excludeKeys) { if ($field instanceof VariantsField) { $excludeKeys[] = 'Variants'; return true; @@ -743,16 +742,7 @@ public function getSidebarHtml(bool $static): string return true; } return false; - }; - - // @TODO remove when the plugin no longer supports Craft 4 - if (!method_exists($fieldLayout, 'getFields')) { - foreach ($fieldLayout->getCustomFields() as $field) { - $checkField($field); - } - } else { - $fieldLayout->getFields($checkField); - } + }); return ProductHelper::renderCardHtml($this, $excludeKeys) . parent::getSidebarHtml($static); } @@ -823,7 +813,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(); @@ -892,29 +882,6 @@ protected static function defineSortOptions(): array return $sortOptions; } - /** - * @param string $attribute - * @return string - * @throws InvalidConfigException - * @TODO remove this method when support for Craft 4 is dropped - */ - protected function tableAttributeHtml(string $attribute): string - { - if (!in_array($attribute, [ - 'shopifyEdit', - 'shopifyStatus', - 'shopifyId', - 'options', - 'tags', - 'variants', - ])) { - /** @phpstan-ignore-next-line */ - return parent::tableAttributeHtml($attribute); - } - - return $this->attributeHtml($attribute); - } - /** * @param string $attribute * @return string diff --git a/src/elements/conditions/products/ProductCondition.php b/src/elements/conditions/products/ProductCondition.php index e1f00696..5144d412 100644 --- a/src/elements/conditions/products/ProductCondition.php +++ b/src/elements/conditions/products/ProductCondition.php @@ -17,21 +17,6 @@ */ class ProductCondition extends ElementCondition { - /** - * @inheritdoc - * @TODO remove this method when support for Craft 4 is dropped - */ - protected function conditionRuleTypes(): array - { - return array_merge(parent::conditionRuleTypes(), [ - ProductTypeConditionRule::class, - ShopifyStatusConditionRule::class, - VendorConditionRule::class, - HandleConditionRule::class, - TagsConditionRule::class, - ]); - } - /** * @inheritdoc */ 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/enums/ApiVersion.php b/src/enums/ApiVersion.php new file mode 100644 index 00000000..3c39d718 --- /dev/null +++ b/src/enums/ApiVersion.php @@ -0,0 +1,21 @@ + + * @since 8.0.0 + */ +enum ApiVersion: string +{ + case January2026 = '2026-01'; + case April2026 = '2026-04'; + case July2026 = '2026-07'; +} diff --git a/src/enums/BulkOperationStatus.php b/src/enums/BulkOperationStatus.php index bfdc3540..f501dc2e 100644 --- a/src/enums/BulkOperationStatus.php +++ b/src/enums/BulkOperationStatus.php @@ -42,17 +42,6 @@ public function statusAsLabel(): string */ public function statusLabelHtml(): string { - // @TODO update this either when Craft 4 support is dropped or 4 gets enums - if (!class_exists(Color::class) || !method_exists(Cp::class, 'statusLabelHtml')) { - $color = match ($this) { - self::Created => 'blue', - self::Processing => 'yellow', - self::Completed => 'green', - default => 'gray', // takes care of draft - }; - return "" . $this->statusAsLabel(); - } - return Cp::statusLabelHtml([ 'color' => match ($this) { self::Queued => Color::Gray, diff --git a/src/enums/WebhookTopics.php b/src/enums/WebhookTopics.php new file mode 100644 index 00000000..bd1f1031 --- /dev/null +++ b/src/enums/WebhookTopics.php @@ -0,0 +1,38 @@ + + * @since 8.0.0 + */ +enum WebhookTopics: string +{ + case ProductsCreate = 'products/create'; + case ProductsUpdate = 'products/update'; + case ProductsDelete = 'products/delete'; + case InventoryLevelsUpdate = 'inventory_levels/update'; + case InventoryItemsUpdate = 'inventory_items/update'; + case BulkOperationsFinish = 'bulk_operations/finish'; + case ShopUpdate = 'shop/update'; + + /** + * Returns the GraphQL WebhookSubscriptionTopic enum string for this topic. + * + * e.g. products/create → PRODUCTS_CREATE + */ + public function toGraphQLEnum(): string + { + return strtoupper(str_replace('/', '_', $this->value)); + } +} diff --git a/src/exceptions/InvalidOAuthException.php b/src/exceptions/InvalidOAuthException.php new file mode 100644 index 00000000..eada0177 --- /dev/null +++ b/src/exceptions/InvalidOAuthException.php @@ -0,0 +1,18 @@ + + * @since 8.0.0 + */ +class InvalidOAuthException extends \RuntimeException +{ +} diff --git a/src/exceptions/ShopifyApiCommunicationException.php b/src/exceptions/ShopifyApiCommunicationException.php new file mode 100644 index 00000000..9efb917f --- /dev/null +++ b/src/exceptions/ShopifyApiCommunicationException.php @@ -0,0 +1,19 @@ + + * @since 8.0.0 + */ +class ShopifyApiCommunicationException extends ShopifyApiException +{ +} diff --git a/src/exceptions/ShopifyApiException.php b/src/exceptions/ShopifyApiException.php new file mode 100644 index 00000000..4dd7acf0 --- /dev/null +++ b/src/exceptions/ShopifyApiException.php @@ -0,0 +1,18 @@ + + * @since 8.0.0 + */ +class ShopifyApiException extends \RuntimeException +{ +} diff --git a/src/fieldlayoutelements/MetafieldsField.php b/src/fieldlayoutelements/MetafieldsField.php index ade31b9c..d39c5e6a 100644 --- a/src/fieldlayoutelements/MetafieldsField.php +++ b/src/fieldlayoutelements/MetafieldsField.php @@ -74,30 +74,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa ]; } - // @TODO remove this when Craft 4 support is dropped - $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute(); - - $tableConfig = [ + return Cp::editableTableHtml([ 'id' => $this->id(), - 'name' => $name, + 'name' => $this->baseInputName(), 'cols' => $cols, 'rows' => $tableData, 'static' => true, - ]; - - return $this->_editableTableHtml($tableConfig); - } - - /** - * @TODO remove this when Craft 4 support is dropped - */ - private function _editableTableHtml(array $tableConfig): string - { - // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4) - if (!is_callable([Cp::class, 'editableTableHtml'])) { - return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig); - } - - return Cp::editableTableHtml($tableConfig); + ]); } } diff --git a/src/fieldlayoutelements/OptionsField.php b/src/fieldlayoutelements/OptionsField.php index 20b1a6cb..4640bc11 100644 --- a/src/fieldlayoutelements/OptionsField.php +++ b/src/fieldlayoutelements/OptionsField.php @@ -85,30 +85,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa } } - // @TODO remove this when Craft 4 support is dropped - $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute(); - - $tableConfig = [ + return Cp::editableTableHtml([ 'id' => $this->id(), - 'name' => $name, + 'name' => $this->baseInputName(), 'cols' => $cols, 'rows' => $tableData, 'static' => true, - ]; - - return $this->_editableTableHtml($tableConfig); - } - - /** - * @TODO remove this when Craft 4 support is dropped - */ - private function _editableTableHtml(array $tableConfig): string - { - // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4) - if (!is_callable([Cp::class, 'editableTableHtml'])) { - return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig); - } - - return Cp::editableTableHtml($tableConfig); + ]); } } diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php index cab9e0e1..e38c5147 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; @@ -86,30 +86,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa Html::endTag('div'); } - // @TODO remove this when Craft 4 support is dropped - $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute(); - - $tableConfig = [ + return Cp::editableTableHtml([ 'id' => $this->id(), - 'name' => $name, + 'name' => $this->baseInputName(), 'cols' => $cols, 'rows' => $variantRows, 'static' => true, - ]; - - return $this->_editableTableHtml($tableConfig); - } - - /** - * @TODO remove this when Craft 4 support is dropped - */ - private function _editableTableHtml(array $tableConfig): string - { - // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4) - if (!is_callable([Cp::class, 'editableTableHtml'])) { - return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig); - } - - return Cp::editableTableHtml($tableConfig); + ]); } } 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..ddd94bd9 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -7,9 +7,8 @@ namespace craft\shopify\handlers; +use craft\shopify\enums\WebhookTopics; use craft\shopify\Plugin; -use Shopify\Webhooks\Handler; -use Shopify\Webhooks\Topics; /** * Webhook handler. @@ -17,28 +16,18 @@ * @author Pixel & Tonic, Inc. * @since 6.0.0 */ -class Webhook implements Handler +class Webhook { - public function handle(string $topic, string $shop, array $body): void + public function handle(WebhookTopics $topic, string $shop, array $body): void { - switch ($topic) { - case Topics::PRODUCTS_UPDATE: - case Topics::PRODUCTS_CREATE: - Plugin::getInstance()->getProducts()->syncProductByShopifyId($body['id']); - break; - case Topics::PRODUCTS_DELETE: - Plugin::getInstance()->getProducts()->deleteProductByShopifyId($body['id']); - break; - case Topics::INVENTORY_ITEMS_UPDATE: - Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']); - break; - case Topics::BULK_OPERATIONS_FINISH: - Plugin::getInstance()->getBulkOperations()->handleBulkOperationFinished($body); - break; - case Topics::SHOP_UPDATE: - // Unfortunately, the shop data in the webhook differs to that returned by the GraphQl API. - Plugin::getInstance()->getApi()->getShop(true); - break; - } + match ($topic) { + WebhookTopics::ProductsCreate, + WebhookTopics::ProductsUpdate => Plugin::getInstance()->getProducts()->syncProductByShopifyGid($body['id']), + WebhookTopics::ProductsDelete => Plugin::getInstance()->getProducts()->deleteProductByShopifyGid($body['id']), + WebhookTopics::InventoryLevelsUpdate => Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['inventory_item_id']), + WebhookTopics::InventoryItemsUpdate => Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']), + WebhookTopics::BulkOperationsFinish => Plugin::getInstance()->getBulkOperations()->handleBulkOperationFinished($body), + WebhookTopics::ShopUpdate => Plugin::getInstance()->getApi()->getShop(true), + }; } } 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/helpers/Product.php b/src/helpers/Product.php index abef6ce9..eb4ac17f 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); @@ -153,16 +153,6 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet */ public static function shopifyStatusHtml(ProductElement $product): string { - // @TODO update this either when Craft 4 support is dropped or 4 gets enums - if (!class_exists(Color::class) || !method_exists(Cp::class, 'statusLabelHtml')) { - $color = match (StringHelper::toLowerCase($product->shopifyStatus)) { - ProductElement::SHOPIFY_STATUS_ACTIVE => 'green', - ProductElement::SHOPIFY_STATUS_ARCHIVED => 'red', - default => 'orange', // takes care of draft - }; - return "" . StringHelper::titleize($product->shopifyStatus); - } - $color = match (StringHelper::toLowerCase($product->shopifyStatus)) { ProductElement::SHOPIFY_STATUS_ACTIVE => Color::Green->value, ProductElement::SHOPIFY_STATUS_ARCHIVED => Color::Red->value, diff --git a/src/helpers/ShopifyHelper.php b/src/helpers/ShopifyHelper.php new file mode 100644 index 00000000..981fd587 --- /dev/null +++ b/src/helpers/ShopifyHelper.php @@ -0,0 +1,72 @@ + + * @since 8.0.0 + */ +class ShopifyHelper +{ + /** + * Validates and normalizes a Shopify shop domain. + * + * Returns the sanitized domain string, or null if the value is not a valid Shopify domain. + */ + public static function sanitizeShopDomain(string $shop): ?string + { + $shop = strtolower(trim($shop)); + + // Strip protocol prefix + $shop = preg_replace('#^https?://#', '', $shop); + + // If no dot, assume it's a myshopify subdomain + if (!str_contains($shop, '.')) { + $shop .= '.myshopify.com'; + } + + if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9\-]*\.(myshopify\.com|myshopify\.io)$/', $shop)) { + return null; + } + + return $shop; + } + + /** + * Validates the HMAC signature on a set of Shopify query parameters. + */ + public static function validateHmac(array $params, string $secret): bool + { + if (!isset($params['hmac'])) { + return false; + } + + $hmac = $params['hmac']; + $computed = hash_hmac('sha256', self::_buildQueryString($params), $secret); + + return hash_equals($computed, $hmac); + } + + private static function _buildQueryString(array $params): string + { + unset($params['hmac']); + ksort($params); + + $pairs = []; + foreach ($params as $key => $value) { + if (is_array($value)) { + $value = '["' . implode('","', $value) . '"]'; + } + $pairs[] = urlencode((string)$key) . '=' . urlencode((string)$value); + } + + return implode('&', $pairs); + } +} diff --git a/src/jobs/ProcessBulkOperationData.php b/src/jobs/ProcessBulkOperationData.php index 0799c122..159d2fe9 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/Settings.php b/src/models/Settings.php index ae892a54..b0e6b939 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -14,10 +14,10 @@ use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\shopify\elements\Product; +use craft\shopify\enums\ApiVersion; +use craft\shopify\helpers\ShopifyHelper; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; -use Shopify\ApiVersion; -use Shopify\Utils; /** * Shopify Settings model. @@ -32,10 +32,14 @@ class Settings extends Model private string $_accessToken = ''; private string $_hostName = ''; + private array $_additionalFeatures = []; + private string $_customScopes = ''; public string $uriFormat = ''; public string $template = ''; private mixed $_productFieldLayout; + public const REQUIRED_SCOPES = ['read_inventory', 'read_product_listings', 'read_products']; + /** * @var string|null Comma separated list of country codes to use for contextual pricing. */ @@ -46,17 +50,19 @@ class Settings extends Model * @see setApiVersion() * @see getApiVersion() */ - private string $_apiVersion = ApiVersion::JANUARY_2026; + private string $_apiVersion = ApiVersion::January2026->value; public function rules(): array { return [ [['clientSecret', 'clientId', 'hostName', 'apiVersion'], 'required'], [['apiVersion'], 'in', 'range' => Plugin::getInstance()->getApi()->getSupportedApiVersions()], + [['additionalFeatures'], 'in', 'range' => array_keys($this->getAdditionalFeaturesOptions()), 'allowArray' => true], + [['customScopes'], 'string', 'skipOnEmpty' => true], [['hostName'], function($attribute) { $hostName = $this->$attribute; - if (Utils::sanitizeShopDomain($hostName) === null) { + if (ShopifyHelper::sanitizeShopDomain($hostName) === null) { $this->addError($attribute, Craft::t('shopify', 'The host name must be a valid Shopify store domain.')); } }, 'skipOnEmpty' => true], @@ -66,10 +72,12 @@ public function rules(): array public function attributes() { $names = parent::attributes(); + $names[] = 'additionalFeatures'; $names[] = 'apiVersion'; $names[] = 'clientId'; $names[] = 'clientSecret'; $names[] = 'contextualPricingCountries'; + $names[] = 'customScopes'; $names[] = 'hostName'; $names[] = 'uriFormat'; $names[] = 'template'; @@ -80,10 +88,12 @@ public function attributes() public function fields(): array { return [ + 'additionalFeatures' => fn() => $this->getAdditionalFeatures(), 'apiVersion' => fn() => $this->getApiVersion(false), 'clientId' => fn() => $this->getClientId(false), 'clientSecret' => fn() => $this->getClientSecret(false), 'contextualPricingCountries' => fn() => $this->getContextualPricingCountries(false), + 'customScopes' => fn() => $this->getCustomScopes(false), 'hostName' => fn() => $this->getHostName(false), 'uriFormat' => 'uriFormat', 'template' => 'template', @@ -96,14 +106,17 @@ public function fields(): array public function attributeLabels(): array { return [ + 'additionalFeatures' => Craft::t('app', 'Additional Features'), + 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), 'authUrl' => Craft::t('shopify', 'Shopify App Auth URL'), 'clientId' => Craft::t('shopify', 'Shopify Client ID'), 'clientSecret' => Craft::t('shopify', 'Shopify Client Secret Key'), - 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), 'contextualPricingCountries' => Craft::t('shopify', 'Context Pricing Countries'), + 'customScopes' => Craft::t('shopify', 'Custom Scopes'), 'hostName' => Craft::t('shopify', 'Shopify Host Name'), - 'uriFormat' => Craft::t('shopify', 'Product URI format'), + 'scopes' => Craft::t('shopify', 'Scopes'), 'template' => Craft::t('shopify', 'Product Template'), + 'uriFormat' => Craft::t('shopify', 'Product URI format'), ]; } @@ -128,37 +141,33 @@ public function getApiVersion(bool $parse = true): string } /** - * @param string $apiKey + * @param string $clientId * @return void - * @since 6.0.0 - * @deprecated in 7.0.0. Use [[setClientId()]] instead. + * @since 7.0.0 */ - public function setApiKey(string $apiKey): void + public function setClientId(string $clientId): void { - Craft::$app->getDeprecator()->log(__METHOD__, '`setApiKey()` method has been deprecated. Use `setClientId()` instead.'); - return; + $this->_clientId = $clientId; } /** * @param bool $parse * @return string - * @since 6.0.0 - * @deprecated in 7.0.0. Use [[getClientId()]] instead. + * @since 7.0.0 */ - public function getApiKey(bool $parse = true): string + public function getClientId(bool $parse = true): string { - Craft::$app->getDeprecator()->log(__METHOD__, '`getApiKey()` method has been deprecated. Use `getClientId()` instead.'); - return $this->getClientId($parse); + return ($parse ? App::parseEnv($this->_clientId) : $this->_clientId) ?? ''; } /** - * @param string $clientId + * @param string $clientSecret * @return void * @since 7.0.0 */ - public function setClientId(string $clientId): void + public function setClientSecret(string $clientSecret): void { - $this->_clientId = $clientId; + $this->_clientSecret = $clientSecret; } /** @@ -166,74 +175,141 @@ public function setClientId(string $clientId): void * @return string * @since 7.0.0 */ - public function getClientId(bool $parse = true): string + public function getClientSecret(bool $parse = true): string { - return ($parse ? App::parseEnv($this->_clientId) : $this->_clientId) ?? ''; + return ($parse ? App::parseEnv($this->_clientSecret) : $this->_clientSecret) ?? ''; } /** - * @param string $apiSecretKey + * @param string $hostName * @return void * @since 6.0.0 - * @deprecated in 7.0.0. Use [[setClientSecret()]] instead. */ - public function setApiSecretKey(string $apiSecretKey): void + public function setHostName(string $hostName): void { - Craft::$app->getDeprecator()->log(__METHOD__, '`setApiSecretKey()` method has been deprecated. Use `setClientSecret()` instead.'); - return; + $this->_hostName = $hostName; } /** * @param bool $parse * @return string * @since 6.0.0 - * @deprecated in 7.0.0. Use [[getClientSecret()]] instead. */ - public function getApiSecretKey(bool $parse = true): string + public function getHostName(bool $parse = true): string { - Craft::$app->getDeprecator()->log(__METHOD__, '`getApiSecretKey()` method has been deprecated. Use `getClientSecret()` instead.'); - return $this->getClientSecret($parse); + return ($parse ? App::parseEnv($this->_hostName) : $this->_hostName) ?? ''; } /** - * @param string $clientSecret + * @param array $additionalFeatures * @return void - * @since 7.0.0 + * @since 7.2.0 */ - public function setClientSecret(string $clientSecret): void + public function setAdditionalFeatures(array $additionalFeatures): void { - $this->_clientSecret = $clientSecret; + $this->_additionalFeatures = $additionalFeatures; } /** - * @param bool $parse - * @return string - * @since 7.0.0 + * @return array + * @since 7.2.0 */ - public function getClientSecret(bool $parse = true): string + public function getAdditionalFeatures(bool $asScopes = false): array { - return ($parse ? App::parseEnv($this->_clientSecret) : $this->_clientSecret) ?? ''; + if ($asScopes && !empty($this->_additionalFeatures)) { + $scopes = []; + foreach ($this->_additionalFeatures as $additionalFeature) { + $adFeat = $this->getAdditionalFeaturesOptions()[$additionalFeature] ?? null; + if ($adFeat) { + $scopes[] = $adFeat['scope']; + } + } + + return $scopes; + } + + return $this->_additionalFeatures; + } + + /** + * @return array + * @since 7.2.0 + */ + public function getAdditionalFeaturesOptions(): array + { + return [ + 'productTranslations' => [ + 'label' => Craft::t('shopify', 'Product Translations'), + 'value' => 'productTranslations', + 'scope' => 'read_locales', + ], + ]; } /** - * @param string $hostName + * @param string $additionalScopes * @return void - * @since 6.0.0 + * @since 7.2.0 */ - public function setHostName(string $hostName): void + public function setCustomScopes(string $additionalScopes): void { - $this->_hostName = $hostName; + // Preserve env var references as-is; normalize plain-text values + if (!str_starts_with($additionalScopes, '$')) { + $additionalScopes = implode(',', array_filter(array_map( + fn($s) => self::_normalizeScope($s), + explode(',', $additionalScopes) + ))); + } + + $this->_customScopes = $additionalScopes; } /** * @param bool $parse * @return string - * @since 6.0.0 + * @since 7.2.0 */ - public function getHostName(bool $parse = true): string + public function getCustomScopes(bool $parse = true): string { - return ($parse ? App::parseEnv($this->_hostName) : $this->_hostName) ?? ''; + return ($parse ? App::parseEnv($this->_customScopes) : $this->_customScopes) ?? ''; + } + + /** + * @param bool $asArray + * @return array|string + * @since 7.2.0 + */ + public function getScopes(bool $asArray = false): array|string + { + $scopes = array_merge(self::REQUIRED_SCOPES, $this->getAdditionalFeatures(true)); + + $customScopes = $this->getCustomScopes(); + if ($customScopes) { + $scopes = array_merge($scopes, array_filter(array_map( + fn($s) => self::_normalizeScope($s), + explode(',', $customScopes) + ))); + } + + $scopes = array_unique($scopes); + asort($scopes); + + if ($asArray) { + return $scopes; + } + + return implode(',', $scopes); + } + + /** + * Normalizes a single scope string: lowercases, trims whitespace, and returns an empty string if the result + * contains characters outside `[a-z0-9_]`. + */ + private static function _normalizeScope(string $scope): string + { + $normalized = strtolower(trim($scope)); + return preg_match('/^[a-z0-9_]+$/', $normalized) ? $normalized : ''; } /** diff --git a/src/models/Variant.php b/src/models/Variant.php index 688e4990..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; @@ -16,6 +17,7 @@ /** * Variant model. * + * @property-read string $shopifyGid * @property-read string $shopifyId * @property-read string $title * @property-read string $sku @@ -31,7 +33,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 +110,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; } @@ -142,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); } /** @@ -165,7 +172,7 @@ public function setMetafields(string|array $value): void */ public function getMetafields(): array { - if (!$this->shopifyId) { + if (!$this->shopifyGid) { return []; } @@ -173,16 +180,9 @@ public function getMetafields(): array return $this->_metaFields; } - $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyId); - - $metafields = $data - ->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); + $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); - $this->setMetafields($metafields->all()); + $this->setMetafields($data->all()); return $this->_metaFields ?? []; } 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..fa68ed9b 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -12,9 +12,14 @@ use craft\helpers\ArrayHelper; use craft\helpers\Json; use craft\helpers\StringHelper; -use craft\log\MonologTarget; +use craft\shopify\auth\OAuthFlow; +use craft\shopify\clients\GraphqlClient; +use craft\shopify\enums\ApiVersion; +use craft\shopify\enums\WebhookTopics; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; +use craft\shopify\exceptions\ShopifyApiCommunicationException; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; @@ -22,23 +27,7 @@ use GraphQL\Query; use GraphQL\QueryBuilder\QueryBuilder; use GraphQL\Variable; -use GuzzleHttp\Client; use Illuminate\Support\Collection; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; -use Shopify\ApiVersion; -use Shopify\Auth\FileSessionStorage; -use Shopify\Auth\OAuth; -use Shopify\Auth\Session; -use Shopify\Clients\Graphql; -use Shopify\Clients\Http; -use Shopify\Clients\HttpClientFactory; -use Shopify\Context; -use Shopify\Exception\MissingArgumentException; -use Shopify\Exception\SessionNotFoundException; -use Shopify\Exception\ShopifyException; -use Shopify\Exception\UninitializedContextException; -use Shopify\Webhooks\Topics; use yii\base\InvalidConfigException; /** @@ -51,19 +40,6 @@ */ class Api extends Component { - /** - * @var string[] - * @since 6.0.0 - */ - public const WEBHOOK_TOPICS = [ - Topics::PRODUCTS_CREATE, - Topics::PRODUCTS_UPDATE, - Topics::PRODUCTS_DELETE, - Topics::INVENTORY_LEVELS_UPDATE, - Topics::BULK_OPERATIONS_FINISH, - Topics::SHOP_UPDATE, - ]; - /** * @since 7.0.0 */ @@ -82,24 +58,31 @@ class Api extends Component public const EVENT_DEFINE_GQL_QUERY_ARGUMENTS = 'defineGqlQueryArguments'; /** - * @var Session|null + * @var string|null */ - private ?Session $_session = null; + private ?string $_accessToken = null; /** - * @var Graphql|null + * @var GraphqlClient|null */ - private ?Graphql $_gqlClient = null; + private ?GraphqlClient $_gqlClient = null; /** - * @return array + * @return string[] * @since 5.3.0 */ public function getSupportedApiVersions(): array { - return [ - ApiVersion::JANUARY_2026, - ]; + return array_column(ApiVersion::cases(), 'value'); + } + + /** + * @return WebhookTopics[] + * @since 8.0.0 + */ + public function getWebhookTopics(): array + { + return WebhookTopics::cases(); } /** @@ -149,6 +132,20 @@ public function getShopGql(): Query return $this->createQuery('shop', $fields); } + /** + * @return Query + * @since 7.2.0 + */ + public function getShopLocalesGql(): Query + { + return $this->createQuery('shopLocales', [ + 'locale', + 'primary', + ], function(QueryBuilder $builder) { + $builder->setArgument('published', true); + }); + } + /** * @param bool $update * @return array|null @@ -176,7 +173,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; @@ -203,6 +200,7 @@ public function getShop(bool $update = false): ?array */ public function getProductGql(?string $id = null): Query { + // Create contextual pricing fields (if required) $contextualPricingCountries = Plugin::getInstance()->getSettings()->getContextualPricingCountries(); $contextualPricing = []; @@ -231,6 +229,32 @@ public function getProductGql(?string $id = null): Query } } + // Create translations fields (if required) + $translations = []; + if (in_array('productTranslations', Plugin::getInstance()->getSettings()->getAdditionalFeatures())) { + try { + $cacheKey = 'shopify:shopLocales:' . Plugin::getInstance()->getSettings()->getHostName(); + $locales = Craft::$app->getCache()->getOrSet($cacheKey, function() { + return $this->query($this->getShopLocalesGql()); + }, 86400); + + if (empty($locales)) { + throw new \Exception('Shop locales data not found in the response.'); + } + + foreach ($locales as $locale) { + if ($locale['primary']) { + continue; + } + + $localeKey = sprintf('translations_%1$s: translations(locale:"%1$s")', $locale['locale']); + $translations[$localeKey] = ['key', 'value']; + } + } catch (\Exception $e) { + Craft::error($e->getMessage(), __METHOD__); + } + } + $fields = [ 'edges' => [ 'node' => [ @@ -345,6 +369,8 @@ public function getProductGql(?string $id = null): Query ], ], 'vendor', + // Add translations to the products query + ...$translations, ], ], ]; @@ -359,8 +385,8 @@ public function getProductGql(?string $id = null): Query return $this->createQuery('products', $fields, function(QueryBuilder $builder) use ($id) { if ($id) { - // Strip Shopify prefix if it exists - $id = str_replace('gid://shopify/Product/', '', $id); + // Extract the numeric ID from a full GID or pass through a bare numeric ID + $id = StringHelper::afterLast($id, '/') ?: $id; $builder->setArgument('query', sprintf('id:%s', $id)); } @@ -426,22 +452,19 @@ public function createQuery(string $name, array $fields, callable $beforeFields * If you need to control how a response is unpacked, use {@see getGqlClient()} directly. * * Under normal circumstances, the selected fields (including `userErrors`, when requested) are returned as an array. - * A `false` return value indicates a low-level communication failure. - * - * All other issues should trigger a {@see ShopifyException}. * * @param Query|string $query * @param array|null $variables * @return mixed Typically an array with the same structure as the selection, or `null` for nonexistent nodes. - * @throws ShopifyException when the response looks unusual (i.e. an `errors` key is present, or a `data` key was not returned) - * @throws SessionNotFoundException if a session can’t be established + * @throws ShopifyApiException when the response looks unusual (i.e. an `errors` key is present, a `data` key was not returned, or `userErrors` was populated), or on a low-level communication failure + * @throws \RuntimeException if a session can't be established * @since 6.0.0 */ public function query(Query|string $query, ?array $variables = null): mixed { // An invalid session will cause everything to fail: - if ($this->getSession() === null) { - throw new SessionNotFoundException(Craft::t('shopify', 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.')); + if (!$this->connect()) { + throw new \RuntimeException(Craft::t('shopify', 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.')); } $payload = ['query' => (string)$query]; @@ -451,8 +474,7 @@ public function query(Query|string $query, ?array $variables = null): mixed } try { - $response = $this->getGqlClient()->query($payload); - $body = $response->getDecodedBody(); + $body = $this->getGqlClient()->query($payload); if (array_key_exists('errors', $body)) { $message = $body['errors']; @@ -462,42 +484,42 @@ public function query(Query|string $query, ?array $variables = null): mixed // Others need to be unpacked from an array: if (is_array($message)) { $message = $message[0]['message']; - // (Shopify also suggests that 400 errors may have a key like `query`, but we haven’t observed this!) + // (Shopify also suggests that 400 errors may have a key like `query`, but we haven't observed this!) } - throw new ShopifyException($message); + throw new ShopifyApiException($message); } // GraphQL responses are always nested inside a `data` key: if (!isset($body['data'])) { - throw new ShopifyException('No data was returned from the GraphQL query.'); + throw new ShopifyApiException('No data was returned from the GraphQL query.'); } $data = $body['data']; - // Queries and mutations have implicit “names” based on the procedure, which is where our data will be in the response. - // The name itself doesn’t matter (we are only sending one query or mutation at a time), so we can just unwrap the “first” item: + // Queries and mutations have implicit "names" based on the procedure, which is where our data will be in the response. + // The name itself doesn't matter (we are only sending one query or mutation at a time), so we can just unwrap the "first" item: $data = ArrayHelper::firstValue($data); // The query may have selected `userErrors`, so we should check and throw: if (!empty($data['userErrors'])) { Craft::error('A GraphQL response included `userErrors`: ' . join(', ', array_column($data['userErrors'], 'message')), __METHOD__); - throw new ShopifyException($data['userErrors'][0]['message']); + throw new ShopifyApiException($data['userErrors'][0]['message']); } return $data; - } catch (ClientExceptionInterface $e) { + } catch (ShopifyApiCommunicationException $e) { // We only intercept communication-related exceptions, here. // Everything else (like a query or mutation issue) is allowed to bubble out so it can be reported to the user. Craft::error('Could not run GraphQL query: ' . $e->getMessage(), __METHOD__); - // Re-throw as an API error: - throw new ShopifyException('An issue occurred while communicating with the Shopify API. Check the logs for more information.'); + // Re-throw as a generic API error: + throw new ShopifyApiException('An issue occurred while communicating with the Shopify API. Check the logs for more information.', 0, $e); } } /** - * Queries the data table for records of the specified type, optionally owned by one or more “parent” objects. + * Queries the data table for records of the specified type, optionally owned by one or more "parent" objects. * * @param string $type * @param string|false|null $parentId @@ -529,103 +551,61 @@ public function getShopifyDataByType(string $type, array|string|null|false $pare * Returns or sets up a GraphQL API client. * * @see query() - * @return Graphql - * @throws MissingArgumentException + * @return GraphqlClient * @since 6.0.0 */ - public function getGqlClient(): Graphql + public function getGqlClient(): GraphqlClient { if ($this->_gqlClient === null) { - $session = $this->getSession(); - - if (!$session) { + if (!$this->_accessToken) { throw new InvalidConfigException('Unable to initialize API session. Check that your API credentials are correct and that you have authorized the app.'); } - $this->_gqlClient = new Graphql($session->getShop(), $session->getAccessToken()); + $pluginSettings = Plugin::getInstance()->getSettings(); + + $this->_gqlClient = new GraphqlClient( + $pluginSettings->getHostName(true), + $this->_accessToken, + $pluginSettings->getApiVersion(), + ); } return $this->_gqlClient; } /** - * Returns or initializes a context + session. + * Ensures the service is initialized with a shop hostname and access token. + * + * Returns true if the plugin is authorized and ready to make API calls, false otherwise. * - * @return Session|null - * @throws MissingArgumentException + * @return bool + * @since 8.0 */ - public function getSession(): ?Session + public function connect(): bool { + if ($this->_accessToken !== null) { + return true; + } + $pluginSettings = Plugin::getInstance()->getSettings(); - if ( - $this->_session === null && - ($pluginSettings->getClientId(true)) && - ($pluginSettings->getClientSecret(true)) - ) { - $this->initializeContext(); - - $hostName = $pluginSettings->getHostName(true); - $accessToken = $this->getAccessToken(shop: $hostName); - - // If there isn't an access token we can't create a session - if ($accessToken) { - $this->_session = new Session( - id: 'NA', - shop: $hostName, - isOnline: false, - state: 'NA' - ); - - $this->_session->setAccessToken($accessToken); // this is the most important part of the authentication - } + if (!$pluginSettings->getClientId(true) || !$pluginSettings->getClientSecret(true)) { + return false; } - return $this->_session; - } + $accessToken = $this->getAccessToken(shop: $pluginSettings->getHostName(true)); - /** - * @return void - * @throws MissingArgumentException - * @throws \yii\base\Exception - * @since 7.0.0 - */ - public function initializeContext(): void - { - $pluginSettings = Plugin::getInstance()->getSettings(); - /** @var MonologTarget $webLogTarget */ - $webLogTarget = Craft::$app->getLog()->targets['web']; - - Context::initialize( - apiKey: $pluginSettings->getClientId(), - apiSecretKey: $pluginSettings->getClientSecret(), - scopes: ['write_products', 'read_products', 'read_inventory'], - // This `hostName` is different from the `shop` value used when creating a Session! - // Shopify wants a name for the host/environment that is *initiating* the API connection. - // Internally, they appear to use this for starting OAuth flows and creating webhooks (but we handle the latter, manually). - hostName: !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', - sessionStorage: new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), - apiVersion: $pluginSettings->getApiVersion(), - isEmbeddedApp: false, - logger: $webLogTarget->getLogger(), - ); - - Context::$HTTP_CLIENT_FACTORY = new class() extends HttpClientFactory { - public function client(): ClientInterface - { - // This is the default client, but we need to add the header for presentment prices - return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]); - } - }; + if ($accessToken) { + $this->_accessToken = $accessToken; + } + + return $this->_accessToken !== null; } /** * @param string|null $code * @param string|null $shop * @return string|null - * @throws ClientExceptionInterface - * @throws UninitializedContextException - * @throws \JsonException * @since 7.0.0 */ public function getAccessToken(?string $code = null, ?string $shop = null, bool $forceRefresh = false): ?string @@ -639,17 +619,18 @@ public function getAccessToken(?string $code = null, ?string $shop = null, bool return null; } - $client = new Http($shop); - try { - $response = $client->post(OAuth::ACCESS_TOKEN_POST_PATH, [ - 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), - 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), - 'code' => $code, - 'expiring' => 0, + $httpClient = Craft::createGuzzleClient(); + $response = $httpClient->post('https://' . $shop . OAuthFlow::ACCESS_TOKEN_POST_PATH, [ + 'json' => [ + 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), + 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), + 'code' => $code, + 'expiring' => 0, + ], ]); - $body = $response->getDecodedBody(); + $body = json_decode((string)$response->getBody(), true); if (!isset($body['access_token'])) { throw new \Exception('No access token returned from Shopify.'); @@ -722,8 +703,7 @@ public function getWebhooks(): Collection /** * @param string $id Shopify webhook subscription GID * @return bool - * @throws MissingArgumentException - * @throws ShopifyException + * @throws ShopifyApiException * @since 6.0.0 */ public function deleteWebhookById(string $id): bool @@ -751,7 +731,7 @@ public function deleteWebhookById(string $id): bool if (!$this->query($mutation, $variables)) { Craft::error(sprintf('No data was returned while deleting webhook %s', $id), __METHOD__); - throw new ShopifyException('The webhook may not have been deleted.'); + throw new ShopifyApiException('The webhook may not have been deleted.'); } return true; diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index 8e6cb586..e6c1dd86 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -15,6 +15,7 @@ use craft\helpers\Queue; use craft\shopify\db\Table; use craft\shopify\enums\BulkOperationStatus; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\jobs\ProcessBulkOperationData; use craft\shopify\models\BulkOperation; use craft\shopify\Plugin; @@ -23,7 +24,6 @@ use GraphQL\Mutation; use GraphQL\Variable; use Illuminate\Support\Collection; -use Shopify\Exception\ShopifyException; use yii\base\InvalidConfigException; use yii\db\Exception; use yii\db\StaleObjectException; @@ -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); } /** @@ -164,7 +176,7 @@ public function nextBulkOperation(): bool try { $bulkOpStatusResponse = Plugin::getInstance()->getApi()->query($bulkOpsStatusQuery); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { return false; } @@ -194,7 +206,7 @@ public function nextBulkOperation(): bool try { $data = Plugin::getInstance()->getApi()->query($mutation, ['query' => $bulkOperation->query]); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { // If there was an issue creating the operation that we haven’t accounted for, just mark it as completed: Craft::error('Could not start bulk operation: ' . $e->getMessage(), __METHOD__); @@ -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..f89e2a53 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -8,12 +8,10 @@ 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; 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; @@ -58,16 +56,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 +111,7 @@ public function syncProductByInventoryItemId($id): void $productId = $item['variant']['product']['id']; - $this->syncProductByShopifyId($productId); + $this->syncProductByShopifyGid($productId); } /** @@ -178,52 +189,72 @@ 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 string $gid + * @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): bool + { + if (!$gid) { + return false; + } + + $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; + } + + /** * @param $id * @return void * @throws \Throwable * @throws StaleObjectException + * @deprecated in 8.0.0. Use [[deleteProductByShopifyGid()]] instead. */ public function deleteProductByShopifyId($id): void { - if ($id) { - if ($product = Product::find()->shopifyId($id)->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); + if (!$id) { + return; } + + $this->deleteProductByShopifyGid($id); } /** - * @param string $shopifyId + * @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 +264,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 @@ -247,23 +290,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); }); } @@ -290,7 +317,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; @@ -309,18 +336,10 @@ public function eagerLoadVariantsForProducts(array $products): array $variants = VariantCollection::make($variantsByProductId[$product->shopifyGid]); if ($metafieldsData->isNotEmpty()) { - $variants->map(function(Variant$variant) use ($metafieldsData) { - $metafields = $metafieldsData->get($variant->shopifyId); + $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/src/translations/en/shopify.php b/src/translations/en/shopify.php index 78fcda77..37b9dffb 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -14,96 +14,119 @@ * @since 0.0.1 */ return [ + 'A comma separated list of country codes used to return contextual pricing.' => 'A comma separated list of country codes used to return contextual pricing.', + 'A comma separated list of custom scopes to add to the API requests.' => 'A comma separated list of custom scopes to add to the API requests.', + 'API scopes required for your app integration, including additional features and custom scopes.' => 'API scopes required for your app integration, including additional features and custom scopes.', 'API Connection' => 'API Connection', 'Add a product' => 'Add a product', 'All products' => 'All products', 'Archived in Shopify' => 'Archived in Shopify', 'Are you sure you want to run a complete sync of all products?' => 'Are you sure you want to run a complete sync of all products?', - 'Are you sure you want to delete this sync?' => 'Are you sure you want to delete this sync?', - 'Are you sure you want to delete this webhook?' => 'Are you sure you want to delete this webhook?', - 'Channel' => 'Channel', + 'Are you sure you want to delete the {topic} webhook?' => 'Are you sure you want to delete the {topic} webhook?', + 'Authorization' => 'Authorization', + 'Authorize' => 'Authorize', + 'Authorize App' => 'Authorize App', 'Completed' => 'Completed', + 'Configure the product’s front-end routing settings.' => 'Configure the product’s front-end routing settings.', + 'Context Pricing Countries' => 'Context Pricing Countries', + 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.' => 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.', 'Couldn’t save settings.' => 'Couldn’t save settings.', - 'Create' => 'Create', + 'Couldn’t update scopes.' => 'Couldn’t update scopes.', + 'Create all webhooks' => 'Create all webhooks', + 'Create missing webhooks' => 'Create missing webhooks', 'Created' => 'Created', 'Created At' => 'Created At', - 'Delete {topic} webhook?' => 'Delete {topic} webhook?', - 'Description HTML' => 'Description HTML', + 'Created at' => 'Created at', + 'Custom Scopes' => 'Custom Scopes', + 'Delete {topic} webhook' => 'Delete {topic} webhook', 'Draft in Shopify' => 'Draft in Shopify', 'Edit variant {title} on Shopify' => 'Edit variant {title} on Shopify', + 'Error authorizing app' => 'Error authorizing app', 'Failed to create products sync' => 'Failed to create products sync', 'Failed to delete sync' => 'Failed to delete sync', - 'General' => 'General', 'Handle' => 'Handle', 'Has variants' => 'Has variants', + 'Images' => 'Images', + 'Invalid or missing HMAC. Please try re-installing the app.' => 'Invalid or missing HMAC. Please try re-installing the app.', 'Key' => 'Key', + 'Leave blank if products don’t have URLs' => 'Leave blank if products don’t have URLs', 'Live' => 'Live', 'Media' => 'Media', 'Meta fields' => 'Meta fields', 'Meta Fields' => 'Meta Fields', 'New Product' => 'New Product', - 'No Shopify session available.' => 'No Shopify session available.', - 'No webhooks exist for this environment' => 'No webhooks exist for this environment', - 'Objects' => 'Objects', + 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.' => 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.', + 'No webhooks exist for this environment.' => 'No webhooks exist for this environment.', + 'Open in Shopify' => 'Open in Shopify', 'Option' => 'Option', 'Options' => 'Options', 'Price' => 'Price', 'Processing' => 'Processing', 'Processing bulk operation data' => 'Processing bulk operation data', 'Product Template' => 'Product Template', + 'Product Translations' => 'Product Translations', 'Product Type' => 'Product Type', + 'Product URI Format' => 'Product URI Format', 'Product URI format' => 'Product URI format', 'Product' => 'Product', 'Products sync created' => 'Products sync created', 'Products' => 'Products', 'Published At' => 'Published At', - 'Published Scope' => 'Published Scope', - 'Published' => 'Published', + 'Published at' => 'Published at', 'Queued' => 'Queued', + 'Routing Settings' => 'Routing Settings', + 'Scopes' => 'Scopes', 'Settings saved.' => 'Settings saved.', 'Settings' => 'Settings', 'Shopify Client ID' => 'Shopify Client ID', 'Shopify Client Secret Key' => 'Shopify Client Secret Key', 'Shopify API Version' => 'Shopify API Version', - 'Shopify Access Token' => 'Shopify Access Token', + 'Shopify App Auth URL' => 'Shopify App Auth URL', 'Shopify Edit' => 'Shopify Edit', 'Shopify Host Name' => 'Shopify Host Name', 'Shopify ID' => 'Shopify ID', - 'Shopify Product' => 'Shopify Product', 'Shopify Products' => 'Shopify Products', + 'Shopify product' => 'Shopify product', + 'Shopify products' => 'Shopify products', 'Shopify Status' => 'Shopify Status', - 'Shopify plugin loaded' => 'Shopify plugin loaded', + 'Shopify Sync' => 'Shopify Sync', 'Shopify' => 'Shopify', 'SKU' => 'SKU', 'Status' => 'Status', 'Supported API versions: {versions}' => 'Supported API versions: {versions}', 'Sync all' => 'Sync all', - 'Sync all Products' => 'Sync all Products', - 'Sync could not be deleted' => 'Sync could not be deleted', 'Sync deleted' => 'Sync deleted', 'Tags' => 'Tags', 'Template suffix' => 'Template suffix', + 'The host name must be a valid Shopify store domain.' => 'The host name must be a valid Shopify store domain.', + 'The Shopify store hostname.' => 'The Shopify store hostname.', + 'The Shopify store {shop} needs to be authorized to connect with this plugin.' => 'The Shopify store {shop} needs to be authorized to connect with this plugin.', + 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.' => 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.', + 'This environment is not subscribed to all the required webhook topics.' => 'This environment is not subscribed to all the required webhook topics.', + 'This environment is subscribed to all the required webhook topics!' => 'This environment is subscribed to all the required webhook topics!', 'This product has no media.' => 'This product has no media.', 'This product has no meta fields.' => 'This product has no meta fields.', 'This product has no options.' => 'This product has no options.', 'This product has no variants.' => 'This product has no variants.', + 'Topic' => 'Topic', 'Total variants' => 'Total variants', - 'Unpublished' => 'Unpublished', + 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.' => 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.', 'Untitled product' => 'Untitled product', 'Updated At' => 'Updated At', - 'Updating product metafields for “{title}”' => 'Updating product metafields for “{title}”', - 'Updating product variants for “{title}”' => 'Updating product variants for “{title}”', + 'Updated at' => 'Updated at', 'Value' => 'Value', 'Values' => 'Values', 'Variant' => 'Variant', 'Variants' => 'Variants', 'Vendor' => 'Vendor', 'View products' => 'View products', + 'Webhook could not be deleted' => 'Webhook could not be deleted', 'Webhook deleted' => 'Webhook deleted', - 'Webhooks could not be deleted' => 'Webhooks could not be deleted', 'Webhooks could not be registered.' => 'Webhooks could not be registered.', - 'Webhooks for the current environment.' => 'Webhooks for the current environment.', 'Webhooks registered.' => 'Webhooks registered.', 'Webhooks' => 'Webhooks', + 'What product URIs should look like.' => 'What product URIs should look like.', + 'Which template should be loaded when a product’s URL is requested.' => 'Which template should be loaded when a product’s URL is requested.', + 'Your Shopify app has been successfully authorized.' => 'Your Shopify app has been successfully authorized.', '{name} option values: {values}' => '{name} option values: {values}', ]; diff --git a/src/utilities/Sync.php b/src/utilities/Sync.php index cf2996bc..bedccf78 100644 --- a/src/utilities/Sync.php +++ b/src/utilities/Sync.php @@ -39,15 +39,6 @@ public static function id(): string return 'shopify-sync'; } - /** - * @inheritdoc - * @TODO remove this method when support for Craft 4 is dropped - */ - public static function iconPath(): ?string - { - return self::icon(); // TODO: Change the autogenerated stub - } - /** * @inheritdoc */ diff --git a/src/webhooks/WebhookRegistry.php b/src/webhooks/WebhookRegistry.php new file mode 100644 index 00000000..02a37ed9 --- /dev/null +++ b/src/webhooks/WebhookRegistry.php @@ -0,0 +1,70 @@ + + * @since 8.0.0 + */ +class WebhookRegistry +{ + private static array $registry = []; + + public static function addHandler(WebhookTopics $topic, object $handler): void + { + self::$registry[$topic->value] = $handler; + } + + public static function getHandler(WebhookTopics $topic): ?object + { + return self::$registry[$topic->value] ?? null; + } + + /** + * Validates the HMAC of an incoming webhook request and dispatches to the registered handler. + * + * @throws \RuntimeException on HMAC failure, missing headers, unknown topic, or missing handler. + */ + public static function process(array $headers, string $rawBody, string $secret): void + { + $get = static function(array $headers, string $key): string { + $value = $headers[$key] ?? $headers[strtolower($key)] ?? ''; + return is_array($value) ? ($value[0] ?? '') : (string)$value; + }; + + $topicValue = $get($headers, 'X-Shopify-Topic'); + $shop = $get($headers, 'X-Shopify-Shop-Domain'); + $hmac = $get($headers, 'X-Shopify-Hmac-SHA256'); + + if (!$topicValue || !$shop || !$hmac) { + throw new \RuntimeException('Missing required Shopify webhook headers.'); + } + + $expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true)); + if (!hash_equals($expected, $hmac)) { + throw new \RuntimeException('Webhook HMAC validation failed.'); + } + + $topic = WebhookTopics::tryFrom($topicValue); + if ($topic === null) { + throw new \RuntimeException("Unknown webhook topic: {$topicValue}"); + } + + $handler = self::getHandler($topic); + if (!$handler) { + throw new \RuntimeException("No handler registered for webhook topic: {$topicValue}"); + } + + $body = json_decode($rawBody, true) ?? []; + $handler->handle($topic, $shop, $body); + } +} diff --git a/tests/.env.example.mysql b/tests/.env.example.mysql index 2be345a8..89879803 100644 --- a/tests/.env.example.mysql +++ b/tests/.env.example.mysql @@ -12,4 +12,20 @@ DB_SCHEMA="public" # Set this to the `entryUrl` param in the `codeception.yml` file. DEFAULT_SITE_URL="https://test.craftcms.test/index.php" FROM_EMAIL_NAME="Craft CMS" -FROM_EMAIL_ADDRESS="info@craftcms.com" \ No newline at end of file +FROM_EMAIL_ADDRESS="info@craftcms.com" + +# Optional: credentials for tests tagged `@group live` (see tests/_support/Helper/RequiresLiveApi.php), +# which exercise the real Shopify API instead of fixtures. Point these at a Shopify Partner +# development store, not a production one. If any of these are unset, live tests are skipped — +# this is how they're skipped in CI, which has no access to real credentials. +# SHOPIFY_LIVE_ACCESS_TOKEN must come from an app that has already completed the plugin's OAuth +# authorization flow (see the README's "Connect to Shopify" section) — Shopify only issues access +# tokens after a real authorization, so this can't be an arbitrary string. +# SHOPIFY_LIVE_HOST_NAME=your-dev-store.myshopify.com +# SHOPIFY_LIVE_CLIENT_ID= +# SHOPIFY_LIVE_CLIENT_SECRET= +# SHOPIFY_LIVE_ACCESS_TOKEN= +# Optional: pins live tests to a specific Shopify API version instead of the plugin's default. +# Useful for checking the plugin against a version ahead of or behind the default, to catch +# API changes early. Must be one of craft\shopify\enums\ApiVersion's supported values. +# SHOPIFY_LIVE_API_VERSION= \ No newline at end of file diff --git a/tests/.env.example.pgsql b/tests/.env.example.pgsql index aa67c08c..ac36e4eb 100644 --- a/tests/.env.example.pgsql +++ b/tests/.env.example.pgsql @@ -12,4 +12,20 @@ DB_SCHEMA="public" # Set this to the `entryUrl` param in the `codeception.yml` file. DEFAULT_SITE_URL="https://test.craftcms.test/index.php" FROM_EMAIL_NAME="Craft CMS" -FROM_EMAIL_ADDRESS="info@craftcms.com" \ No newline at end of file +FROM_EMAIL_ADDRESS="info@craftcms.com" + +# Optional: credentials for tests tagged `@group live` (see tests/_support/Helper/RequiresLiveApi.php), +# which exercise the real Shopify API instead of fixtures. Point these at a Shopify Partner +# development store, not a production one. If any of these are unset, live tests are skipped — +# this is how they're skipped in CI, which has no access to real credentials. +# SHOPIFY_LIVE_ACCESS_TOKEN must come from an app that has already completed the plugin's OAuth +# authorization flow (see the README's "Connect to Shopify" section) — Shopify only issues access +# tokens after a real authorization, so this can't be an arbitrary string. +# SHOPIFY_LIVE_HOST_NAME=your-dev-store.myshopify.com +# SHOPIFY_LIVE_CLIENT_ID= +# SHOPIFY_LIVE_CLIENT_SECRET= +# SHOPIFY_LIVE_ACCESS_TOKEN= +# Optional: pins live tests to a specific Shopify API version instead of the plugin's default. +# Useful for checking the plugin against a version ahead of or behind the default, to catch +# API changes early. Must be one of craft\shopify\enums\ApiVersion's supported values. +# SHOPIFY_LIVE_API_VERSION= \ No newline at end of file diff --git a/tests/_craft/templates/shopify-collections-query.twig b/tests/_craft/templates/shopify-collections-query.twig new file mode 100644 index 00000000..3f35b6ec --- /dev/null +++ b/tests/_craft/templates/shopify-collections-query.twig @@ -0,0 +1,15 @@ +{# Mirrors the README's "API Service" `craft.shopify.api.query()` example. #} +{% set gql %} + { + collections(first: 10) { + nodes { + id + title + } + } + } +{% endset %} + +{% set response = craft.shopify.api.query(gql) %} +{% set collections = response.nodes ?? [] %} +{{- collections|length -}} diff --git a/tests/_craft/templates/shopify-gid-query.twig b/tests/_craft/templates/shopify-gid-query.twig new file mode 100644 index 00000000..6bc33d84 --- /dev/null +++ b/tests/_craft/templates/shopify-gid-query.twig @@ -0,0 +1,3 @@ +{# Mirrors the README's `#### shopifyGid` query-param example. #} +{% set product = craft.shopifyProducts.shopifyGid(gid).one() %} +{{- product ? product.shopifyGid : '' -}} diff --git a/tests/_craft/templates/shopify-options-search.twig b/tests/_craft/templates/shopify-options-search.twig new file mode 100644 index 00000000..626faeed --- /dev/null +++ b/tests/_craft/templates/shopify-options-search.twig @@ -0,0 +1,3 @@ +{# Mirrors the README's `#### options` query-param example. #} +{% set products = craft.shopifyProducts.search('*yellow*').all() %} +{{- products|length -}}:{{- products[0] is defined ? products[0].shopifyGid : '' -}} diff --git a/tests/_support/Helper/RequiresLiveApi.php b/tests/_support/Helper/RequiresLiveApi.php new file mode 100644 index 00000000..a5856181 --- /dev/null +++ b/tests/_support/Helper/RequiresLiveApi.php @@ -0,0 +1,67 @@ +getSettings(); + $settings->setHostName($hostName); + $settings->setClientId($clientId); + $settings->setClientSecret($clientSecret); + $settings->setAccessToken($accessToken); + + if ($apiVersion) { + $supportedVersions = Plugin::getInstance()->getApi()->getSupportedApiVersions(); + if (!in_array($apiVersion, $supportedVersions, true)) { + self::fail(sprintf( + 'SHOPIFY_LIVE_API_VERSION "%s" is not one of the versions this plugin supports (%s).', + $apiVersion, + implode(', ', $supportedVersions), + )); + } + + $settings->setApiVersion($apiVersion); + } + } +} 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..e6a8a813 100644 --- a/tests/fixtures/ShopifyDataFixture.php +++ b/tests/fixtures/ShopifyDataFixture.php @@ -29,9 +29,9 @@ 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'], + 'data' => $row['data'], 'parentId' => $row['parentId'], 'uid' => $uid, 'dateCreated' => $row['dateCreated'], 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/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/helpers/ShopifyHelperTest.php b/tests/unit/helpers/ShopifyHelperTest.php new file mode 100644 index 00000000..a1e7d452 --- /dev/null +++ b/tests/unit/helpers/ShopifyHelperTest.php @@ -0,0 +1,273 @@ + 'my-store.myshopify.com'], 'test_secret_shhh')); + } + + public function testValidateHmacAcceptsValidSignature(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'timestamp' => '1700000000', + 'code' => 'abc123', + // Precomputed: hash_hmac('sha256', 'code=abc123&shop=my-shop.myshopify.com×tamp=1700000000', 'test_secret_shhh') + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacRejectsTamperedParam(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'timestamp' => '1700000000', + 'code' => 'tampered-code', + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacRejectsWrongSecret(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'timestamp' => '1700000000', + 'code' => 'abc123', + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'a-completely-different-secret')); + } + + public function testValidateHmacRejectsGarbageHmacValue(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'hmac' => 'not-a-real-signature', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacIsOrderIndependent(): void + { + // Params are sorted internally, so submitting them in a different key order + // should still validate against the same signature. + $params = [ + 'timestamp' => '1700000000', + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + 'code' => 'abc123', + 'shop' => 'my-shop.myshopify.com', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacHandlesArrayValuedParams(): void + { + $params = [ + 'ids' => ['1', '2'], + 'shop' => 'my-shop.myshopify.com', + // Precomputed: hash_hmac('sha256', 'ids=%5B%221%22%2C%222%22%5D&shop=my-shop.myshopify.com', 'test_secret_shhh') + 'hmac' => '89ef6f86296cd4a979fc7c17d7d6f915a89aa2216cf430723a5f9ca131435285', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacAcceptsSignatureOfEmptyParamSet(): void + { + // Edge case: with no params besides `hmac`, the signed query string is empty. + $params = [ + // Precomputed: hash_hmac('sha256', '', 'test_secret_shhh') + 'hmac' => '82783bf294739b92ae7e7be2ff20a30ec2cd80cb8a2536916644268572182039', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + // ------------------------------------------------------------------------- + // validateHmac: encoding / canonicalization edge cases + // ------------------------------------------------------------------------- + + public function testValidateHmacEncodesSpacesAsPlusSign(): void + { + // urlencode() (not rawurlencode()) must be used, so spaces become `+`, matching + // Shopify's own canonical form. Using `%20` here would fail to validate. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'foo bar', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=foo+bar', 'test_secret_shhh') + 'hmac' => '218d30f535682d6c49b473bcaef52bba0748f6d55645cbb470b36d0991521615', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacPercentEncodesAmpersandAndEqualsInValues(): void + { + // A value containing `&` or `=` must be percent-encoded so it can't be mistaken + // for an additional parameter or key/value separator. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'a&b=c', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=a%26b%3Dc', 'test_secret_shhh') + 'hmac' => '8b3daf029726ca3702e44cfc269b32e306ce678d20b806319ac3375e91ff08e5', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacPercentEncodesLiteralPercentSign(): void + { + // A literal `%` in a value must itself be escaped (to `%25`), or it would be + // misread as the start of a percent-encoded sequence. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => '100%', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=100%25', 'test_secret_shhh') + 'hmac' => '40d8975574763e4a0b04b8b0bf355f6346818e2c136a902487188abd37516975', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacPercentEncodesMultibyteUnicodeValue(): void + { + // Multibyte UTF-8 values must be percent-encoded byte-for-byte. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'café', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=caf%C3%A9', 'test_secret_shhh') + 'hmac' => '4e447001ed0870f3526076eb709599631f3b79b0c5fe72b1c3de99eda821bc0b', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacRejectsMismatchedEncodingOfSpaces(): void + { + // A signature computed against `%20`-encoded spaces (RFC 3986) rather than `+` + // (RFC 1738 / urlencode) must NOT validate. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'foo bar', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=foo%20bar', 'test_secret_shhh') + 'hmac' => 'e466c63f23328c630405d39eb7b0840046472e586d3aea536324a70d878430ed', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } +} 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/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/ApiLiveTest.php b/tests/unit/services/ApiLiveTest.php new file mode 100644 index 00000000..797ca319 --- /dev/null +++ b/tests/unit/services/ApiLiveTest.php @@ -0,0 +1,65 @@ +requireLiveApi(); + } + + public function testCanFetchShop(): void + { + $shop = Plugin::getInstance()->getApi()->getShop(true); + + self::assertIsArray($shop); + self::assertArrayHasKey('name', $shop); + self::assertArrayHasKey('myshopifyDomain', $shop); + self::assertNotEmpty($shop['name']); + } + + /** + * Fetches the webhook subscriptions registered for this plugin's webhook URL. Read-only — + * doesn't register or delete anything, so it's safe to run against a real store repeatedly. + * + * If the URL doesn't match any subscriptions registered in the store (e.g. a local test + * environment's computed URL), this legitimately returns an empty collection — that's not + * a failure on its own, just confirmation the call and response shape are correct. + */ + public function testCanFetchWebhooks(): void + { + $webhooks = Plugin::getInstance()->getApi()->getWebhooks(); + + self::assertInstanceOf(Collection::class, $webhooks); + + foreach ($webhooks as $webhook) { + self::assertArrayHasKey('id', $webhook); + self::assertArrayHasKey('topic', $webhook); + self::assertArrayHasKey('uri', $webhook); + self::assertStringStartsWith('gid://shopify/WebhookSubscription/', $webhook['id']); + } + } +} diff --git a/tests/unit/services/BulkOperationsTest.php b/tests/unit/services/BulkOperationsTest.php index 5f3c4f86..7553249a 100644 --- a/tests/unit/services/BulkOperationsTest.php +++ b/tests/unit/services/BulkOperationsTest.php @@ -55,32 +55,48 @@ 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); } + // ------------------------------------------------------------------------- + // 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 // ------------------------------------------------------------------------- @@ -98,12 +114,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 +137,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 +156,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 +196,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 +223,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 +257,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..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; @@ -63,13 +64,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 +78,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,13 +89,31 @@ 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); } + // ------------------------------------------------------------------------- + // 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 // ------------------------------------------------------------------------- @@ -164,7 +183,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 +193,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, @@ -224,7 +243,7 @@ public function setImages(array $images): void public function setMetafields(array $metafields): void { - $this->metafields = $metafields; + $this->metafields = MetafieldHelper::normalizeToMap($metafields); } }; }, $shopifyGids); diff --git a/tests/unit/twig/ApiQueryTemplateLiveTest.php b/tests/unit/twig/ApiQueryTemplateLiveTest.php new file mode 100644 index 00000000..a52dd698 --- /dev/null +++ b/tests/unit/twig/ApiQueryTemplateLiveTest.php @@ -0,0 +1,59 @@ +requireLiveApi(); + } + + public function testCollectionsQueryTemplateFromReadmeExample(): void + { + // Mirrors the README's "API Service" example: `craft.shopify.api.query(gql)` + // against `collections(first: 10) { nodes { id title } }`. + $output = Craft::$app->getView()->renderTemplate('shopify-collections-query', [], View::TEMPLATE_MODE_SITE); + + self::assertMatchesRegularExpression('/^\d+$/', trim($output)); + + // Confirm the shape of what the template consumed, by running the same query directly. + $response = Plugin::getInstance()->getApi()->query('{ collections(first: 10) { nodes { id title } } }'); + $collections = $response['nodes'] ?? []; + + self::assertSame((string)count($collections), trim($output)); + + foreach ($collections as $collection) { + self::assertArrayHasKey('id', $collection); + self::assertArrayHasKey('title', $collection); + self::assertStringStartsWith('gid://shopify/Collection/', $collection['id']); + } + } +} diff --git a/tests/unit/twig/ProductQueryTemplatesTest.php b/tests/unit/twig/ProductQueryTemplatesTest.php new file mode 100644 index 00000000..adfeaa75 --- /dev/null +++ b/tests/unit/twig/ProductQueryTemplatesTest.php @@ -0,0 +1,93 @@ + ['class' => ShopifyDataFixture::class], + ]; + } + + protected function _before(): void + { + parent::_before(); + + Plugin::getInstance()->getProducts()->createOrUpdateProduct([ + 'id' => self::PRODUCT_GID, + 'title' => 'TIMBERLAND | MENS 6 INCH PREMIUM BOOT', + 'descriptionHtml' => null, + 'createdAt' => '2026-01-01T00:00:00Z', + 'handle' => 'timberland-mens-6-inch-premium-boot', + 'options' => [], + 'productType' => null, + 'publishedAt' => '2026-01-01T00:00:00Z', + 'status' => 'ACTIVE', + 'tags' => ['egnition-sample-data', 'men', 'timberland', 'winter'], + 'templateSuffix' => null, + 'updatedAt' => '2026-01-01T00:00:00Z', + 'vendor' => 'TIMBERLAND', + ]); + } + + public function testShopifyGidQueryParamResolvesProductFromReadmeExample(): void + { + // Mirrors the README's `#### shopifyGid` example: + // `.shopifyGid('gid://shopify/Product/123456789')` + $output = Craft::$app->getView()->renderTemplate('shopify-gid-query', [ + 'gid' => self::PRODUCT_GID, + ], View::TEMPLATE_MODE_SITE); + + self::assertSame(self::PRODUCT_GID, trim($output)); + } + + public function testOptionsSearchParamResolvesProductFromReadmeExample(): void + { + // `options` is sourced entirely from the `shopify_data` join (see class + // docblock) — createOrUpdateProduct() can't override it, so this relies + // on the fixture's own "Color: yellow" option, set up in _before(). + + // Search indexing only runs synchronously on console requests in real + // usage (e.g. queue-driven syncs); our test module simulates a web + // request, so it's queued instead. Index explicitly to match that. + $product = Product::find()->shopifyGid(self::PRODUCT_GID)->status(null)->one(); + Craft::$app->getSearch()->indexElementAttributes($product); + + // Mirrors the README's `#### options` example: `.search('*yellow*')` + $output = Craft::$app->getView()->renderTemplate('shopify-options-search', [], View::TEMPLATE_MODE_SITE); + + self::assertSame('1:' . self::PRODUCT_GID, trim($output)); + } +} diff --git a/tests/unit/webhooks/WebhookRegistryTest.php b/tests/unit/webhooks/WebhookRegistryTest.php new file mode 100644 index 00000000..f16535f9 --- /dev/null +++ b/tests/unit/webhooks/WebhookRegistryTest.php @@ -0,0 +1,340 @@ +_resetRegistry(); + } + + protected function _after(): void + { + $this->_resetRegistry(); + } + + // ------------------------------------------------------------------------- + // addHandler / getHandler + // ------------------------------------------------------------------------- + + public function testGetHandlerReturnsNullWhenNoneRegistered(): void + { + self::assertNull(WebhookRegistry::getHandler(WebhookTopics::ProductsCreate)); + } + + public function testAddHandlerIsRetrievableByGetHandler(): void + { + $handler = new class { + public function handle(): void + { + } + }; + + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $handler); + + self::assertSame($handler, WebhookRegistry::getHandler(WebhookTopics::ProductsCreate)); + } + + public function testAddHandlerOverwritesExistingHandlerForSameTopic(): void + { + $first = new class { + public function handle(): void + { + } + }; + $second = new class { + public function handle(): void + { + } + }; + + WebhookRegistry::addHandler(WebhookTopics::ShopUpdate, $first); + WebhookRegistry::addHandler(WebhookTopics::ShopUpdate, $second); + + self::assertSame($second, WebhookRegistry::getHandler(WebhookTopics::ShopUpdate)); + } + + public function testAddHandlerDoesNotAffectOtherTopics(): void + { + $handler = new class { + public function handle(): void + { + } + }; + + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $handler); + + self::assertNull(WebhookRegistry::getHandler(WebhookTopics::ProductsDelete)); + } + + // ------------------------------------------------------------------------- + // process — happy path + // ------------------------------------------------------------------------- + + public function testProcessDispatchesToRegisteredHandlerWithCorrectArguments(): void + { + $received = null; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function(...$args) use (&$received) { + $received = $args; + })); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + + self::assertNotNull($received); + [$topic, $shop, $body] = $received; + self::assertSame(WebhookTopics::ProductsCreate, $topic); + self::assertSame(self::SHOP, $shop); + self::assertEquals(['id' => 123, 'title' => 'Test Product'], $body); + } + + public function testProcessAcceptsLowercaseHeaderKeys(): void + { + $called = false; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function() use (&$called) { + $called = true; + })); + + WebhookRegistry::process( + [ + 'x-shopify-topic' => 'products/create', + 'x-shopify-shop-domain' => self::SHOP, + 'x-shopify-hmac-sha256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + + self::assertTrue($called); + } + + public function testProcessAcceptsArrayHeaderValues(): void + { + $called = false; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function() use (&$called) { + $called = true; + })); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => ['products/create'], + 'X-Shopify-Shop-Domain' => [self::SHOP], + 'X-Shopify-Hmac-SHA256' => [self::VALID_BODY_HMAC], + ], + self::VALID_BODY, + self::SECRET, + ); + + self::assertTrue($called); + } + + public function testProcessPassesEmptyArrayForNonJsonBody(): void + { + $received = null; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function(...$args) use (&$received) { + $received = $args; + })); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::INVALID_JSON_BODY_HMAC, + ], + self::INVALID_JSON_BODY, + self::SECRET, + ); + + self::assertEquals([], $received[2]); + } + + // ------------------------------------------------------------------------- + // process — failure paths + // ------------------------------------------------------------------------- + + public function testProcessThrowsWhenTopicHeaderMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing required Shopify webhook headers.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsWhenShopHeaderMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing required Shopify webhook headers.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsWhenHmacHeaderMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing required Shopify webhook headers.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsOnInvalidHmac(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Webhook HMAC validation failed.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => 'not-a-real-signature', + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsOnInvalidHmacWhenBodyIsTamperedAfterSigning(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Webhook HMAC validation failed.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + '{"id":123,"title":"Tampered Product"}', + self::SECRET, + ); + } + + public function testProcessThrowsOnInvalidHmacWithWrongSecret(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Webhook HMAC validation failed.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + 'a-completely-different-secret', + ); + } + + public function testProcessThrowsOnUnknownTopic(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unknown webhook topic: orders/create'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'orders/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsWhenNoHandlerRegisteredForTopic(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No handler registered for webhook topic: products/create'); + + // Deliberately not calling WebhookRegistry::addHandler() for this topic. + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function _spyHandler(callable $onHandle): object + { + return new class($onHandle) { + public function __construct(private $onHandle) + { + } + + public function handle(...$args): void + { + ($this->onHandle)(...$args); + } + }; + } + + private function _resetRegistry(): void + { + $property = new \ReflectionProperty(WebhookRegistry::class, 'registry'); + $property->setAccessible(true); + $property->setValue(null, []); + } +}