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: +  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 = <<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, []); + } +}