Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

use MyParcelNL\Pdk\App\Account\Contract\PdkAccountRepositoryInterface;
use MyParcelNL\Pdk\App\Installer\Migration\AbstractTimestampedMigration;
use MyParcelNL\Pdk\Carrier\Repository\CarrierCapabilitiesRepository;
use MyParcelNL\Pdk\Facade\Logger;
use MyParcelNL\Pdk\Facade\Pdk;

/**
* Re-fetches the stored carrier data so insurance limits are in the flat format.
*
* 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 are absent from that older
* data, so insurance would be unavailable until something refreshed it. Fetching the contract
* definitions again rewrites the stored carriers in the shape the PDK expects.
*/
return new class extends AbstractTimestampedMigration {
public function up(): void
{
/** @var PdkAccountRepositoryInterface $accountRepository */
$accountRepository = Pdk::get(PdkAccountRepositoryInterface::class);
$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.
$shop = $account && $account->shops ? $account->shops->first() : null;
Comment thread
FreekVR marked this conversation as resolved.
Comment on lines +22 to +27

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();
Comment thread
FreekVR marked this conversation as resolved.
} 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);
}
};
5 changes: 5 additions & 0 deletions tests/Bootstrap/MockPsPdkBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
);
Expand Down
146 changes: 146 additions & 0 deletions tests/Unit/Migration/RefreshCarrierCapabilitiesMigrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

/** @noinspection PhpUnhandledExceptionInspection,StaticClosureCanBeUsedInspection */

declare(strict_types=1);

namespace MyParcelNL\PrestaShop\Migration;

use MyParcelNL\Pdk\App\Account\Contract\PdkAccountRepositoryInterface;
use MyParcelNL\Pdk\App\Installer\Contract\TimestampedMigrationInterface;
use MyParcelNL\Pdk\Carrier\Collection\CarrierCollection;
use MyParcelNL\Pdk\Carrier\Repository\CarrierCapabilitiesRepository;
use MyParcelNL\Pdk\Facade\Pdk;
use MyParcelNL\Pdk\SdkApi\Service\CoreApi\Shipment\CapabilitiesService;
use MyParcelNL\Pdk\Storage\Contract\StorageInterface;
use MyParcelNL\Pdk\Tests\Api\Response\ExampleGetAccountsResponse;
use MyParcelNL\Pdk\Tests\Bootstrap\MockApi;
use MyParcelNL\Pdk\Tests\Bootstrap\TestBootstrapper;
use MyParcelNL\Pdk\Tests\SdkApi\MockSdkApiHandler;
use MyParcelNL\Pdk\Tests\SdkApi\Response\ExampleContractDefinitionsResponse;
use MyParcelNL\PrestaShop\Tests\Uses\UsesMockPsPdkInstance;
use RuntimeException;
use function MyParcelNL\Pdk\Tests\mockPdkProperties;
use function MyParcelNL\Pdk\Tests\usesShared;

usesShared(new UsesMockPsPdkInstance());

/**
* Loads the migration the same way the installer does: require the file and take the
* returned anonymous-class instance.
*/
function loadRefreshCarrierCapabilitiesMigration(): TimestampedMigrationInterface
{
return require __DIR__ . '/../../../src/Migration/2026_07_29_113506_refresh_carrier_capabilities.php';
}

it('is a timestamped migration the installer can discover', function () {
$migration = loadRefreshCarrierCapabilitiesMigration();

// The installer injects identity from the filename, so the migration must accept it
// and report it back rather than deriving one itself.
$migration->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);
Comment on lines +141 to +145
})->with('insurance shapes from the api');
Loading