From edd073ac95e3aae9e629b77bbd93647a1c0544b3 Mon Sep 17 00:00:00 2001 From: Freek van Rijt Date: Wed, 29 Jul 2026 14:28:52 +0200 Subject: [PATCH 1/5] test: stop unit tests reaching the live api The test bootstrapper swaps the client adapter for a mock, which covers the legacy API layer. The SdkApi services used to build their own HTTP client and slipped past it, so any test touching capabilities, shipping rule implications or whoami sent a real request to the live API. It surfaced as a 401 rather than a useful failure. Those services now take their transport through the constructor, so binding it once points every one of them at the mock queue, including any service added later. Resolves INT-1699 Co-Authored-By: Claude Opus 5 (1M context) --- tests/Bootstrap/MockPsPdkBootstrapper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Bootstrap/MockPsPdkBootstrapper.php b/tests/Bootstrap/MockPsPdkBootstrapper.php index afaf0ef7..3f215913 100644 --- a/tests/Bootstrap/MockPsPdkBootstrapper.php +++ b/tests/Bootstrap/MockPsPdkBootstrapper.php @@ -10,6 +10,7 @@ use MyParcelNL\Pdk\Base\Contract\ConfigInterface; use MyParcelNL\Pdk\Base\FileSystemInterface; use MyParcelNL\Pdk\Language\Contract\LanguageServiceInterface; +use MyParcelNL\Pdk\SdkApi\Contract\SdkClientFactoryInterface; use MyParcelNL\Pdk\Storage\Contract\StorageInterface; use MyParcelNL\Pdk\Storage\MemoryCacheStorage; use MyParcelNL\Pdk\Tests\Api\Guzzle7ClientAdapter; @@ -20,6 +21,7 @@ use MyParcelNL\Pdk\Tests\Bootstrap\MockLogger; use MyParcelNL\Pdk\Tests\Bootstrap\MockMemoryCacheStorage; use MyParcelNL\Pdk\Tests\Bootstrap\MockPdk; +use MyParcelNL\Pdk\Tests\SdkApi\MockSdkClientFactory; use MyParcelNL\PrestaShop\Pdk\Base\PsPdkBootstrapper; use MyParcelNL\PrestaShop\Tests\Bootstrap\Contract\StaticMockInterface; use Psr\Log\LoggerInterface; @@ -80,6 +82,9 @@ protected function getAdditionalConfig( PdkInterface::class => get(MockPdk::class), StorageInterface::class => get(MockMemoryCacheStorage::class), LanguageServiceInterface::class => get(MockLanguageService::class), + // Covers every SdkApi service at once, the same way the client adapter above + // covers every legacy API service. Without it they reach the live API. + SdkClientFactoryInterface::class => get(MockSdkClientFactory::class), ], self::$config ); From 290403cfd23bd1cddb85c8380f9cf3c986ea16b2 Mon Sep 17 00:00:00 2001 From: Freek van Rijt Date: Wed, 29 Jul 2026 14:29:01 +0200 Subject: [PATCH 2/5] fix(insurance): refresh stored carrier data so insurance keeps working Carrier data stored before this release holds insurance limits in the nested wrapper the MyParcel API is removing. The PDK now reads the flat limits, which that older data does not have, so insurance would be unavailable in the shop until something refreshed it. Adds a migration that fetches the contract definitions again and rewrites the stored carriers in the shape the PDK expects. It runs once per install. When the API cannot be reached the migration fails on purpose so it is retried on the next load, rather than leaving the old data in place. Resolves INT-1699 Co-Authored-By: Claude Opus 5 (1M context) --- ...29_113506_refresh_carrier_capabilities.php | 53 +++++++ ...efreshCarrierCapabilitiesMigrationTest.php | 139 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php create mode 100644 tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php diff --git a/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php new file mode 100644 index 00000000..2ed25708 --- /dev/null +++ b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php @@ -0,0 +1,53 @@ +getAccount(true); + // PHPStan types Account::$shops as a non-null ShopCollection, but the guard is kept + // intentionally to stay safe against partial/corrupted account data during upgrade. + // @phpstan-ignore booleanAnd.rightAlwaysTrue + $shop = $account && $account->shops ? $account->shops->first() : null; + + if (! $shop) { + Logger::debug('No account or shop available; skipping carrier capabilities refresh.'); + + return; + } + + /** @var CarrierCapabilitiesRepository $capabilitiesRepository */ + $capabilitiesRepository = Pdk::get(CarrierCapabilitiesRepository::class); + + try { + $shop->carriers = $capabilitiesRepository->getContractDefinitions(); + } catch (Throwable $exception) { + // Re-throw so the migration is not marked as applied, letting it retry on the next + // load instead of leaving the carriers on the old shape. + Logger::warning('Failed to refresh carrier capabilities; migration will retry.', [ + 'exception' => $exception->getMessage(), + ]); + + throw $exception; + } + + $accountRepository->store($account); + } +}; diff --git a/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php b/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php new file mode 100644 index 00000000..3777a72a --- /dev/null +++ b/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php @@ -0,0 +1,139 @@ +setIdentity('2026_07_29_113506_refresh_carrier_capabilities'); + + expect($migration)->toBeInstanceOf(TimestampedMigrationInterface::class) + ->and($migration->getId())->toBe('2026_07_29_113506_refresh_carrier_capabilities'); +}); + +it('skips without failing when no account or shop is available', function () { + /** @var PdkAccountRepositoryInterface $accountRepo */ + $accountRepo = Pdk::get(PdkAccountRepositoryInterface::class); + + // No account configured, so a forced refresh returns null. Skipping beats fataling: + // a fresh install has nothing to refresh. + loadRefreshCarrierCapabilitiesMigration()->up(); + + expect($accountRepo->getAccount())->toBeNull(); +}); + +it('rethrows when fetching carrier definitions fails so the migration retries', function () { + TestBootstrapper::hasAccount(); + + $throwingRepo = new class( + Pdk::get(StorageInterface::class), + Pdk::get(CapabilitiesService::class) + ) extends CarrierCapabilitiesRepository { + public function getContractDefinitions(?string $carrier = null): CarrierCollection + { + throw new RuntimeException('API unavailable'); + } + }; + + mockPdkProperties([CarrierCapabilitiesRepository::class => $throwingRepo]); + + // Throwing is the only way to retry: the installer marks a migration as applied straight + // after up() returns, so swallowing the error would strand the old carrier data. + expect(fn() => loadRefreshCarrierCapabilitiesMigration()->up())->toThrow(RuntimeException::class); +}); + +dataset('insurance shapes from the api', [ + // What the API sends today. The nested wrapper carries different amounts, so if the wrong + // set of limits ever survived, the assertions below would catch it. + 'flat limits alongside the deprecated nested wrapper' => [ + [ + 'min' => ['amount' => 0, 'currency' => 'EUR'], + 'max' => ['amount' => 500_000, 'currency' => 'EUR'], + 'default' => ['amount' => 0, 'currency' => 'EUR'], + 'insuredAmount' => [ + 'min' => ['amount' => 1, 'currency' => 'EUR'], + 'max' => ['amount' => 2, 'currency' => 'EUR'], + 'default' => ['amount' => 3, 'currency' => 'EUR'], + ], + ], + ], + // What the API sends once the nested wrapper is removed. + 'flat limits only' => [ + [ + 'min' => ['amount' => 0, 'currency' => 'EUR'], + 'max' => ['amount' => 500_000, 'currency' => 'EUR'], + 'default' => ['amount' => 0, 'currency' => 'EUR'], + ], + ], +]); + +it('stores only the flat insurance limits', function (array $insurance) { + TestBootstrapper::hasAccount(); + // The migration forces an account refresh, which calls the accounts endpoint. + MockApi::enqueue(new ExampleGetAccountsResponse()); + // Goes through the real CapabilitiesService and repository, so the nested wrapper is + // dropped by the code that actually does it rather than by a stub. + MockSdkApiHandler::enqueue(new ExampleContractDefinitionsResponse([ + [ + 'carrier' => 'POSTNL', + 'packageTypes' => ['PACKAGE'], + 'deliveryTypes' => ['STANDARD_DELIVERY'], + 'transactionTypes' => ['B2C'], + 'options' => [ + 'insurance' => array_merge( + ['isSelectedByDefault' => false, 'isRequired' => false], + $insurance + ), + ], + ], + ])); + + loadRefreshCarrierCapabilitiesMigration()->up(); + + /** @var PdkAccountRepositoryInterface $accountRepo */ + $accountRepo = Pdk::get(PdkAccountRepositoryInterface::class); + $carrier = $accountRepo->getAccount() + ->shops->first() + ->carriers->firstWhere('carrier', 'POSTNL'); + $stored = $carrier->options->getInsurance(); + + // Same stored result either way: the flat limits, and no nested wrapper left behind. + expect($stored->getInsuredAmount())->toBeNull() + ->and($stored->getMin()->getAmount())->toBe(0) + ->and($stored->getMax()->getAmount())->toBe(500_000) + ->and($stored->getDefault()->getAmount())->toBe(0); +})->with('insurance shapes from the api'); From 07322736649b2072451ceb8fd145254ebac3211e Mon Sep 17 00:00:00 2001 From: Freek van Rijt Date: Wed, 29 Jul 2026 15:55:03 +0200 Subject: [PATCH 3/5] fix(insurance): log the full exception when the carrier refresh fails Matches the context Migration5_3_0 already logs for the same failure, so both refresh paths report a failure the same way. Resolves INT-1699 Co-Authored-By: Claude Opus 5 (1M context) --- .../2026_07_29_113506_refresh_carrier_capabilities.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php index 2ed25708..5faa6646 100644 --- a/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php +++ b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php @@ -24,7 +24,6 @@ public function up(): void $account = $accountRepository->getAccount(true); // PHPStan types Account::$shops as a non-null ShopCollection, but the guard is kept // intentionally to stay safe against partial/corrupted account data during upgrade. - // @phpstan-ignore booleanAnd.rightAlwaysTrue $shop = $account && $account->shops ? $account->shops->first() : null; if (! $shop) { @@ -42,7 +41,10 @@ public function up(): void // Re-throw so the migration is not marked as applied, letting it retry on the next // load instead of leaving the carriers on the old shape. Logger::warning('Failed to refresh carrier capabilities; migration will retry.', [ - 'exception' => $exception->getMessage(), + 'message' => $exception->getMessage(), + 'file' => $exception->getFile() . ':' . $exception->getLine(), + 'class' => get_class($exception), + 'trace' => $exception->getTraceAsString(), ]); throw $exception; From 7438681cc2cd400aef859faecd64b88f71818a43 Mon Sep 17 00:00:00 2001 From: Freek van Rijt Date: Thu, 6 Aug 2026 11:34:57 +0200 Subject: [PATCH 4/5] fix(migration): keep the shop up when the carrier refresh cannot reach the api The carrier refresh now reports failure through the PDK's markFailed() instead of throwing. It is still retried on the next load, but a briefly unreachable API no longer takes the page down with it. Requires the PDK release containing markFailed(). Resolves INT-1699 Part of INT-1695 Co-Authored-By: Claude Opus 5 (1M context) --- ..._07_29_113506_refresh_carrier_capabilities.php | 8 ++++---- .../RefreshCarrierCapabilitiesMigrationTest.php | 15 +++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php index 5faa6646..dde146ed 100644 --- a/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php +++ b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php @@ -38,16 +38,16 @@ public function up(): void try { $shop->carriers = $capabilitiesRepository->getContractDefinitions(); } catch (Throwable $exception) { - // Re-throw so the migration is not marked as applied, letting it retry on the next - // load instead of leaving the carriers on the old shape. - Logger::warning('Failed to refresh carrier capabilities; migration will retry.', [ + // Reporting failure leaves the migration unrecorded, so it is attempted again on the + // next load. Throwing would do that too, but it would take the page down with it. + $this->markFailed('Failed to refresh carrier capabilities.', [ 'message' => $exception->getMessage(), 'file' => $exception->getFile() . ':' . $exception->getLine(), 'class' => get_class($exception), 'trace' => $exception->getTraceAsString(), ]); - throw $exception; + return; } $accountRepository->store($account); diff --git a/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php b/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php index 3777a72a..3347758b 100644 --- a/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php +++ b/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php @@ -56,8 +56,11 @@ function loadRefreshCarrierCapabilitiesMigration(): TimestampedMigrationInterfac expect($accountRepo->getAccount())->toBeNull(); }); -it('rethrows when fetching carrier definitions fails so the migration retries', function () { +it('reports failure instead of throwing when fetching carrier definitions fails', function () { TestBootstrapper::hasAccount(); + // The migration forces an account refresh before it gets as far as the carriers. Without a + // response queued that call throws, and the test would pass on the wrong exception. + MockApi::enqueue(new ExampleGetAccountsResponse()); $throwingRepo = new class( Pdk::get(StorageInterface::class), @@ -71,9 +74,13 @@ public function getContractDefinitions(?string $carrier = null): CarrierCollecti mockPdkProperties([CarrierCapabilitiesRepository::class => $throwingRepo]); - // Throwing is the only way to retry: the installer marks a migration as applied straight - // after up() returns, so swallowing the error would strand the old carrier data. - expect(fn() => loadRefreshCarrierCapabilitiesMigration()->up())->toThrow(RuntimeException::class); + $migration = loadRefreshCarrierCapabilitiesMigration(); + $migration->up(); + + // Reporting failure keeps the migration out of applied_migrations, so it is attempted again + // on the next load. Reaching this line at all is the other half of the point: a carrier API + // that is briefly unavailable no longer takes the page down with it. + expect($migration->hasFailed())->toBeTrue(); }); dataset('insurance shapes from the api', [ From ff95a6939564bd82ca41fc097fc496fc34fa7b62 Mon Sep 17 00:00:00 2001 From: Freek van Rijt Date: Thu, 6 Aug 2026 13:48:07 +0200 Subject: [PATCH 5/5] build(deps): require pdk 4.7.0 for markFailed() 4.7.0 is the first release carrying markFailed(), which the carrier refresh migration calls. The flat insurance format is not in it yet, so the nested wrapper test stays red until myparcelnl/pdk#511 ships. Part of INT-1695 Co-Authored-By: Claude Opus 5 (1M context) --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 816d8b4f..61d500dd 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "myparcelnl/pdk": "^4.6.0", + "myparcelnl/pdk": "^4.7.0", "myparcelnl/sdk": "^11.0.0-beta.28", "php": ">=7.4.0" }, diff --git a/composer.lock b/composer.lock index a11a9240..377d79cc 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": "627bb2d4c727732b681d9f528a737f88", + "content-hash": "1f6dba61e0696d2eaab78d9744427e9f", "packages": [ { "name": "fruitcake/php-cors", @@ -471,16 +471,16 @@ }, { "name": "myparcelnl/pdk", - "version": "4.6.0", + "version": "4.7.0", "source": { "type": "git", "url": "https://github.com/myparcelnl/pdk.git", - "reference": "27aaefc453445c2bbfa42db2546bc9fcb9cbb02f" + "reference": "ed9c0d5e919a87c0daf37fadd20b87c51c141fc6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myparcelnl/pdk/zipball/27aaefc453445c2bbfa42db2546bc9fcb9cbb02f", - "reference": "27aaefc453445c2bbfa42db2546bc9fcb9cbb02f", + "url": "https://api.github.com/repos/myparcelnl/pdk/zipball/ed9c0d5e919a87c0daf37fadd20b87c51c141fc6", + "reference": "ed9c0d5e919a87c0daf37fadd20b87c51c141fc6", "shasum": "" }, "require": { @@ -527,9 +527,9 @@ "homepage": "https://myparcel.nl", "support": { "issues": "https://github.com/myparcelnl/pdk/issues", - "source": "https://github.com/myparcelnl/pdk/tree/v4.6.0" + "source": "https://github.com/myparcelnl/pdk/tree/v4.7.0" }, - "time": "2026-08-06T09:12:04+00:00" + "time": "2026-08-06T11:23:43+00:00" }, { "name": "myparcelnl/sdk",