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", 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..dde146ed --- /dev/null +++ b/src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php @@ -0,0 +1,55 @@ +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. + $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) { + // 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(), + ]); + + return; + } + + $accountRepository->store($account); + } +}; 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 ); diff --git a/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php b/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php new file mode 100644 index 00000000..3347758b --- /dev/null +++ b/tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php @@ -0,0 +1,146 @@ +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('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), + Pdk::get(CapabilitiesService::class) + ) extends CarrierCapabilitiesRepository { + public function getContractDefinitions(?string $carrier = null): CarrierCollection + { + throw new RuntimeException('API unavailable'); + } + }; + + mockPdkProperties([CarrierCapabilitiesRepository::class => $throwingRepo]); + + $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', [ + // 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');