27-05-2026 DHL Modul v2.1 / Optimierung tracking
This commit is contained in:
parent
036595be94
commit
2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions
41
tests/Unit/Dhl/CreateShipmentJobSerializationTest.php
Normal file
41
tests/Unit/Dhl/CreateShipmentJobSerializationTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CreateShipmentJob;
|
||||
use App\Models\ShoppingOrder;
|
||||
|
||||
it('never serializes DHL credentials into the queue payload', function () {
|
||||
$order = new ShoppingOrder;
|
||||
$order->id = 4711;
|
||||
|
||||
$dhlConfig = [
|
||||
'base_url' => 'https://api-eu.dhl.com',
|
||||
'api_key' => 'super-secret-api-key',
|
||||
'username' => 'mivita-user',
|
||||
'password' => 'super-secret-password',
|
||||
'api_secret' => 'super-secret-api-secret',
|
||||
'billing_number' => '63144073550101',
|
||||
];
|
||||
|
||||
$job = new CreateShipmentJob($order, 2.5, ['priority' => 'normal'], $dhlConfig);
|
||||
|
||||
$serialized = serialize($job);
|
||||
|
||||
expect($serialized)
|
||||
->not->toContain('super-secret-api-key')
|
||||
->not->toContain('super-secret-password')
|
||||
->not->toContain('super-secret-api-secret')
|
||||
->not->toContain('mivita-user')
|
||||
->not->toContain('63144073550101');
|
||||
});
|
||||
|
||||
it('does not expose a dhlConfig property on the job instance', function () {
|
||||
$order = new ShoppingOrder;
|
||||
$order->id = 1;
|
||||
|
||||
$job = new CreateShipmentJob($order, 1.0, [], [
|
||||
'api_key' => 'should-not-be-stored',
|
||||
'password' => 'should-not-be-stored',
|
||||
]);
|
||||
|
||||
expect(property_exists($job, 'dhlConfig'))->toBeFalse();
|
||||
});
|
||||
47
tests/Unit/Dhl/DhlConfigCachingTest.php
Normal file
47
tests/Unit/Dhl/DhlConfigCachingTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\SettingController;
|
||||
|
||||
beforeEach(function () {
|
||||
SettingController::flushDhlConfigCache();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
SettingController::flushDhlConfigCache();
|
||||
});
|
||||
|
||||
it('returns the cached DHL configuration without re-reading settings', function () {
|
||||
$cached = [
|
||||
'base_url' => 'https://api-eu.dhl.com',
|
||||
'api_key' => 'cached-api-key',
|
||||
'username' => 'cached-user',
|
||||
'password' => 'cached-password',
|
||||
'account_numbers' => ['V01PAK' => '63144073550101'],
|
||||
];
|
||||
|
||||
$reflection = new ReflectionClass(SettingController::class);
|
||||
$property = $reflection->getProperty('cachedDhlConfig');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, $cached);
|
||||
|
||||
$controller = new SettingController;
|
||||
|
||||
expect($controller->getDhlConfig())->toBe($cached);
|
||||
|
||||
$secondCall = $controller->getDhlConfig();
|
||||
|
||||
expect($secondCall)->toBe($cached);
|
||||
});
|
||||
|
||||
it('flushes the DHL configuration cache on demand', function () {
|
||||
$reflection = new ReflectionClass(SettingController::class);
|
||||
$property = $reflection->getProperty('cachedDhlConfig');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, ['api_key' => 'cached-api-key']);
|
||||
|
||||
expect($property->getValue())->toBe(['api_key' => 'cached-api-key']);
|
||||
|
||||
SettingController::flushDhlConfigCache();
|
||||
|
||||
expect($property->getValue())->toBeNull();
|
||||
});
|
||||
47
tests/Unit/Dhl/DhlModalAuthorizationTest.php
Normal file
47
tests/Unit/Dhl/DhlModalAuthorizationTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\ModalController;
|
||||
use App\User;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
function invokeDhlModalAuth(?User $user): void
|
||||
{
|
||||
if ($user !== null) {
|
||||
Auth::shouldReceive('user')->andReturn($user);
|
||||
} else {
|
||||
Auth::shouldReceive('user')->andReturnNull();
|
||||
}
|
||||
|
||||
$controller = new ModalController;
|
||||
$method = (new ReflectionClass(ModalController::class))->getMethod('authorizeDhlShipmentModal');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($controller);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('rejects guests for the DHL shipment modal', function () {
|
||||
invokeDhlModalAuth(null);
|
||||
})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.');
|
||||
|
||||
it('rejects VIP users (admin == 1) for the DHL shipment modal', function () {
|
||||
$vip = (new User)->forceFill(['admin' => 1]);
|
||||
|
||||
invokeDhlModalAuth($vip);
|
||||
})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.');
|
||||
|
||||
it('rejects regular consultants for the DHL shipment modal', function () {
|
||||
$consultant = (new User)->forceFill(['admin' => 0]);
|
||||
|
||||
invokeDhlModalAuth($consultant);
|
||||
})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.');
|
||||
|
||||
it('allows real admin users (admin >= 2) for the DHL shipment modal', function () {
|
||||
$admin = (new User)->forceFill(['admin' => 2]);
|
||||
|
||||
invokeDhlModalAuth($admin);
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\DhlProductResolver;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
|
|
@ -57,10 +60,74 @@ it('uses configured international destination countries', function () {
|
|||
}
|
||||
});
|
||||
|
||||
it('uses saved DHL international countries even with env config priority', function () {
|
||||
$createdSettingsTable = ensureDhlSettingsTableForResolverTest();
|
||||
|
||||
try {
|
||||
config([
|
||||
'dhl.config_source' => 'env',
|
||||
'dhl.international_countries' => ['AT'],
|
||||
]);
|
||||
|
||||
Setting::whereSlug('dhl_international_countries')->delete();
|
||||
Setting::setContentBySlug('dhl_international_countries', ['FR'], 'object');
|
||||
|
||||
expect((new DhlProductResolver)->getSupportedInternationalCountries())->toBe(['FR']);
|
||||
} finally {
|
||||
Setting::whereSlug('dhl_international_countries')->delete();
|
||||
|
||||
if ($createdSettingsTable) {
|
||||
Schema::drop('settings');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an intentionally empty saved DHL international country list', function () {
|
||||
$createdSettingsTable = ensureDhlSettingsTableForResolverTest();
|
||||
|
||||
try {
|
||||
config([
|
||||
'dhl.config_source' => 'env',
|
||||
'dhl.international_countries' => ['AT'],
|
||||
]);
|
||||
|
||||
Setting::whereSlug('dhl_international_countries')->delete();
|
||||
Setting::setContentBySlug('dhl_international_countries', [], 'object');
|
||||
|
||||
expect((new DhlProductResolver)->getSupportedInternationalCountries())->toBe([]);
|
||||
} finally {
|
||||
Setting::whereSlug('dhl_international_countries')->delete();
|
||||
|
||||
if ($createdSettingsTable) {
|
||||
Schema::drop('settings');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes configurable international countries', function () {
|
||||
expect(DhlProductResolver::normalizeCountryCodeList([' at ', 'DE', 'XX', 'FR', 'AT']))->toBe(['AT', 'FR']);
|
||||
});
|
||||
|
||||
function ensureDhlSettingsTableForResolverTest(): bool
|
||||
{
|
||||
if (Schema::hasTable('settings')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('slug')->index();
|
||||
$table->string('type')->nullable();
|
||||
$table->json('object')->nullable();
|
||||
$table->text('full_text')->nullable();
|
||||
$table->text('text')->nullable();
|
||||
$table->integer('int')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
it('describes DHL product scope for preflight checks', function () {
|
||||
$resolver = new DhlProductResolver;
|
||||
|
||||
|
|
|
|||
15
tests/Unit/Dhl/DhlRouteRegistrationTest.php
Normal file
15
tests/Unit/Dhl/DhlRouteRegistrationTest.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
it('does not register the public tracking route inside the CRM admin group', function () {
|
||||
$crmRoutes = file_get_contents(base_path('routes/domains/crm.php'));
|
||||
|
||||
expect($crmRoutes)
|
||||
->not->toMatch('/Route::(get|post|match|any)\([^)]*\)->name\([\'"]public\.tracking[\'"]\)/');
|
||||
});
|
||||
|
||||
it('still exposes the public tracking route on the main domain', function () {
|
||||
$mainRoutes = file_get_contents(base_path('routes/domains/main.php'));
|
||||
|
||||
expect($mainRoutes)
|
||||
->toMatch('/Route::get\([\'"]\/tracking[\'"][^)]*\)->name\([\'"]public\.tracking[\'"]\)/');
|
||||
});
|
||||
116
tests/Unit/Dhl/DhlSanitizeLoggingTest.php
Normal file
116
tests/Unit/Dhl/DhlSanitizeLoggingTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
use App\Services\DhlShipmentService;
|
||||
|
||||
it('redacts DHL credentials from configuration logs', function () {
|
||||
$config = [
|
||||
'base_url' => 'https://api-eu.dhl.com',
|
||||
'api_key' => 'super-secret-api-key',
|
||||
'username' => 'mivita-user',
|
||||
'password' => 'super-secret-password',
|
||||
'api_secret' => 'super-secret-api-secret',
|
||||
'billing_number' => '63144073550101',
|
||||
'use_queue' => false,
|
||||
'default_product' => 'V01PAK',
|
||||
'print_only_if_codeable' => true,
|
||||
'international_countries' => ['AT', 'ES'],
|
||||
'account_numbers' => [
|
||||
'V01PAK' => '63144073550101',
|
||||
'V62KP' => '63144073556201',
|
||||
'V53PAK' => '63144073555301',
|
||||
'V07PAK' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$sanitized = DhlShipmentService::sanitizeDhlConfigForLog($config);
|
||||
$serialized = json_encode($sanitized);
|
||||
|
||||
expect($sanitized)
|
||||
->toHaveKey('has_api_key', true)
|
||||
->toHaveKey('has_username', true)
|
||||
->toHaveKey('has_password', true)
|
||||
->toHaveKey('has_api_secret', true)
|
||||
->toHaveKey('base_url', 'https://api-eu.dhl.com')
|
||||
->toHaveKey('international_countries', ['AT', 'ES'])
|
||||
->and($sanitized['account_numbers_configured'])->toEqualCanonicalizing(['V01PAK', 'V62KP', 'V53PAK']);
|
||||
|
||||
expect($serialized)
|
||||
->not->toContain('super-secret-api-key')
|
||||
->not->toContain('super-secret-password')
|
||||
->not->toContain('super-secret-api-secret')
|
||||
->not->toContain('mivita-user')
|
||||
->not->toContain('63144073550101');
|
||||
});
|
||||
|
||||
it('marks missing DHL credentials as not configured', function () {
|
||||
$sanitized = DhlShipmentService::sanitizeDhlConfigForLog([
|
||||
'base_url' => null,
|
||||
'api_key' => '',
|
||||
'username' => null,
|
||||
'password' => '',
|
||||
'account_numbers' => [
|
||||
'V01PAK' => '',
|
||||
'V62KP' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
expect($sanitized)
|
||||
->toHaveKey('has_api_key', false)
|
||||
->toHaveKey('has_username', false)
|
||||
->toHaveKey('has_password', false)
|
||||
->toHaveKey('account_numbers_configured', []);
|
||||
});
|
||||
|
||||
it('redacts personally identifiable information from order data logs', function () {
|
||||
$orderData = [
|
||||
'order_id' => 4711,
|
||||
'product_code' => 'V01PAK',
|
||||
'weight_kg' => 1.25,
|
||||
'label_format' => 'PDF',
|
||||
'print_format' => 'A4',
|
||||
'print_only_if_codeable' => true,
|
||||
'reference' => 'Order-4711',
|
||||
'shipper' => [
|
||||
'name' => 'mivita care gmbh',
|
||||
'street' => 'Leinfeld',
|
||||
'houseNumber' => '2',
|
||||
'postalCode' => '87755',
|
||||
'city' => 'Kirchhaslach',
|
||||
'country' => 'DE',
|
||||
'email' => 'versand@mivita.care',
|
||||
'phone' => '+49 123 456789',
|
||||
],
|
||||
'consignee' => [
|
||||
'name' => 'Max Mustermann',
|
||||
'street' => 'Hauptstrasse',
|
||||
'houseNumber' => '5',
|
||||
'postalCode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'country' => 'DE',
|
||||
'email' => 'max@example.com',
|
||||
'phone' => '+4930123456',
|
||||
'postNumber' => '1234567890',
|
||||
],
|
||||
];
|
||||
|
||||
$sanitized = DhlShipmentService::sanitizeOrderDataForLog($orderData);
|
||||
$serialized = json_encode($sanitized);
|
||||
|
||||
expect($sanitized)
|
||||
->toHaveKey('order_id', 4711)
|
||||
->toHaveKey('product_code', 'V01PAK')
|
||||
->toHaveKey('weight_kg', 1.25)
|
||||
->toHaveKey('consignee_country', 'DE')
|
||||
->toHaveKey('consignee_postal_prefix', '10')
|
||||
->toHaveKey('consignee_has_post_number', true)
|
||||
->toHaveKey('has_reference', true);
|
||||
|
||||
expect($serialized)
|
||||
->not->toContain('Max Mustermann')
|
||||
->not->toContain('Hauptstrasse')
|
||||
->not->toContain('max@example.com')
|
||||
->not->toContain('+4930123456')
|
||||
->not->toContain('1234567890')
|
||||
->not->toContain('10115')
|
||||
->not->toContain('Berlin');
|
||||
});
|
||||
108
tests/Unit/Dhl/DhlTrackingAuthErrorTest.php
Normal file
108
tests/Unit/Dhl/DhlTrackingAuthErrorTest.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Services\DhlTrackingService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
// Pre-populate the process-wide DHL config cache (introduced in Phase 10)
|
||||
// so the SettingController::getDhlConfig() call inside DhlTrackingService
|
||||
// does not hit the database during the unit test.
|
||||
$reflection = new ReflectionClass(SettingController::class);
|
||||
$property = $reflection->getProperty('cachedDhlConfig');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, [
|
||||
'api_key' => 'cached-test-api-key-1234',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
SettingController::flushDhlConfigCache();
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('returns an explicit auth_error and a meaningful message on HTTP 401', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Unauthorized'], 401),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->trackShipment('00340434292135100148');
|
||||
|
||||
expect($result)
|
||||
->toHaveKey('success', false)
|
||||
->toHaveKey('auth_error', true)
|
||||
->toHaveKey('http_status', 401)
|
||||
->toHaveKey('api_used', 'unified');
|
||||
|
||||
expect($result['message'])
|
||||
->toContain('Authentifizierung fehlgeschlagen')
|
||||
->toContain('HTTP 401')
|
||||
->toContain('Shipment Tracking - Unified');
|
||||
|
||||
// No fallback request to a phantom Parcel DE tracking endpoint must occur.
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('marks HTTP 403 as auth error too', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Forbidden'], 403),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->trackShipment('00340434292135100148');
|
||||
|
||||
expect($result['auth_error'])->toBeTrue()
|
||||
->and($result['http_status'])->toBe(403)
|
||||
->and($result['message'])->toContain('HTTP 403');
|
||||
});
|
||||
|
||||
it('distinguishes a "not found" response from a 401 auth error', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['shipments' => []], 200),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->trackShipment('99999999999999999999');
|
||||
|
||||
expect($result)
|
||||
->toHaveKey('success', false)
|
||||
->toHaveKey('not_found', true)
|
||||
->and($result['message'])->toBe('Sendung nicht gefunden oder noch nicht im DHL-System erfasst.');
|
||||
|
||||
expect($result)->not->toHaveKey('auth_error');
|
||||
});
|
||||
|
||||
it('does not fall back to the non-existent parcel-de tracking endpoint anymore', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Unauthorized'], 401),
|
||||
'api-eu.dhl.com/parcel/de/tracking*' => Http::response('should never be called', 500),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$service->trackShipment('00340434292135100148');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertNotSent(function ($request) {
|
||||
return str_contains($request->url(), '/parcel/de/tracking');
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts the api key in the auth-error log payload', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Log::spy();
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$service->trackShipment('00340434292135100148');
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldHaveReceived('error')
|
||||
->withArgs(function (string $message, array $context) {
|
||||
return str_contains($message, 'authentication failed')
|
||||
&& ($context['api_key_suffix'] ?? null) === '***1234'
|
||||
&& ! str_contains(json_encode($context), 'cached-test-api-key');
|
||||
});
|
||||
});
|
||||
132
tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php
Normal file
132
tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Services\DhlTrackingService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$reflection = new ReflectionClass(SettingController::class);
|
||||
$property = $reflection->getProperty('cachedDhlConfig');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, [
|
||||
'api_key' => 'cached-test-api-key-7777',
|
||||
'use_queue' => false,
|
||||
]);
|
||||
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
DhlTrackingService::setCallIntervalSeconds(0);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
SettingController::flushDhlConfigCache();
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
DhlTrackingService::setCallIntervalSeconds(5);
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return DhlShipment&\Mockery\MockInterface
|
||||
*/
|
||||
function makeTrackableShipment(int $id, string $trackingNumber)
|
||||
{
|
||||
/** @var DhlShipment&\Mockery\MockInterface $shipment */
|
||||
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||
$shipment->id = $id;
|
||||
$shipment->dhl_shipment_no = $trackingNumber;
|
||||
$shipment->status = 'in_transit';
|
||||
|
||||
return $shipment;
|
||||
}
|
||||
|
||||
it('fires exactly one DHL request per shipment - no pseudo-batch comma list anymore', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([
|
||||
'shipments' => [
|
||||
[
|
||||
'id' => 'A1',
|
||||
'status' => [
|
||||
'statusCode' => 'transit',
|
||||
'status' => 'Sendung in Zustellung',
|
||||
'timestamp' => '2026-05-27T12:00:00+02:00',
|
||||
],
|
||||
'events' => [],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$shipments = new Collection([
|
||||
makeTrackableShipment(1, 'A1'),
|
||||
makeTrackableShipment(2, 'B2'),
|
||||
makeTrackableShipment(3, 'C3'),
|
||||
]);
|
||||
|
||||
foreach ($shipments as $shipment) {
|
||||
$shipment->shouldReceive('update')->andReturnTrue();
|
||||
}
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$stats = $service->updateTrackingBatch($shipments);
|
||||
|
||||
expect($stats['updated'])->toBe(3);
|
||||
expect($stats['failed'])->toBe(0);
|
||||
|
||||
// 3 shipments -> exactly 3 HTTP calls, each with a single trackingNumber.
|
||||
Http::assertSentCount(3);
|
||||
Http::assertSent(function ($request) {
|
||||
$value = $request->data()['trackingNumber'] ?? null;
|
||||
|
||||
return $value !== null && ! str_contains((string) $value, ',');
|
||||
});
|
||||
});
|
||||
|
||||
it('sleeps between calls when the throttle is enabled', function () {
|
||||
DhlTrackingService::setCallIntervalSeconds(1);
|
||||
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([
|
||||
'shipments' => [
|
||||
[
|
||||
'id' => 'A1',
|
||||
'status' => ['statusCode' => 'transit', 'status' => 'In Zustellung', 'timestamp' => '2026-05-27T12:00:00+02:00'],
|
||||
'events' => [],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$shipments = new Collection([
|
||||
makeTrackableShipment(1, 'A1'),
|
||||
makeTrackableShipment(2, 'A1'),
|
||||
makeTrackableShipment(3, 'A1'),
|
||||
]);
|
||||
foreach ($shipments as $shipment) {
|
||||
$shipment->shouldReceive('update')->andReturnTrue();
|
||||
}
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
|
||||
$start = microtime(true);
|
||||
$stats = $service->updateTrackingBatch($shipments);
|
||||
$elapsed = microtime(true) - $start;
|
||||
|
||||
expect($stats['updated'])->toBe(3);
|
||||
// 3 calls with a 1s gap between calls -> >=2s total wall-clock time.
|
||||
expect($elapsed)->toBeGreaterThanOrEqual(2.0);
|
||||
});
|
||||
|
||||
it('respects setCallIntervalSeconds(0) for the test suite', function () {
|
||||
expect(DhlTrackingService::getCallIntervalSeconds())->toBe(0);
|
||||
|
||||
DhlTrackingService::setCallIntervalSeconds(5);
|
||||
expect(DhlTrackingService::getCallIntervalSeconds())->toBe(5);
|
||||
|
||||
DhlTrackingService::setCallIntervalSeconds(-99);
|
||||
expect(DhlTrackingService::getCallIntervalSeconds())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not have a trackMultipleShipments method anymore (removed in Phase 13)', function () {
|
||||
expect(method_exists(DhlTrackingService::class, 'trackMultipleShipments'))->toBeFalse();
|
||||
});
|
||||
220
tests/Unit/Dhl/DhlTrackingRateLimitTest.php
Normal file
220
tests/Unit/Dhl/DhlTrackingRateLimitTest.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Services\DhlTrackingService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$reflection = new ReflectionClass(SettingController::class);
|
||||
$property = $reflection->getProperty('cachedDhlConfig');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, [
|
||||
'api_key' => 'cached-test-api-key-9999',
|
||||
'use_queue' => false,
|
||||
]);
|
||||
|
||||
// The 429 path activates a process-wide quota pause via the cache,
|
||||
// which would otherwise leak between tests and short-circuit later
|
||||
// assertions that expect a real HTTP request.
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
|
||||
// Batch tracking sleeps 5 seconds between calls in production; tests
|
||||
// must not actually sleep, otherwise the suite would take minutes.
|
||||
DhlTrackingService::setCallIntervalSeconds(0);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
SettingController::flushDhlConfigCache();
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('returns an explicit rate_limited flag with a German operator-facing message on HTTP 429', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(
|
||||
['status' => 429, 'title' => 'Too Many Requests'],
|
||||
429,
|
||||
['Retry-After' => '120']
|
||||
),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->trackShipment('00340434292135100148');
|
||||
|
||||
expect($result)
|
||||
->toHaveKey('success', false)
|
||||
->toHaveKey('rate_limited', true)
|
||||
->toHaveKey('http_status', 429)
|
||||
->toHaveKey('retry_after', 120)
|
||||
->toHaveKey('api_used', 'unified');
|
||||
|
||||
// The message must reflect the actual DHL standard limits documented at
|
||||
// https://developer.dhl.com/api-reference/shipment-tracking#rate-limits
|
||||
// and must NOT accuse the operator of using a sandbox key - that was an
|
||||
// incorrect Phase 12 assumption.
|
||||
expect($result['message'])
|
||||
->toContain('Tageslimit erreicht')
|
||||
->toContain('HTTP 429')
|
||||
->toContain('250 Aufrufe pro Tag')
|
||||
->toContain('Quota-Erhoehung')
|
||||
->toContain('2 Minute')
|
||||
->not->toContain('Sandbox-Demo-Key');
|
||||
});
|
||||
|
||||
it('aborts the batch immediately on HTTP 429 and does not update last_tracked_at', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Too Many Requests'], 429),
|
||||
]);
|
||||
|
||||
/**
|
||||
* @var Collection<int, DhlShipment&\Mockery\MockInterface>
|
||||
*/
|
||||
$shipments = collect(range(1, 12))->map(function (int $i) {
|
||||
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||
$shipment->id = $i;
|
||||
$shipment->dhl_shipment_no = '003404342921351001'.str_pad((string) $i, 2, '0', STR_PAD_LEFT);
|
||||
$shipment->status = 'in_transit';
|
||||
$shipment->shouldNotReceive('update');
|
||||
|
||||
return $shipment;
|
||||
});
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$stats = $service->updateTrackingBatch($shipments);
|
||||
|
||||
// Phase 13: one HTTP call per shipment, abort on first 429. So only
|
||||
// the first shipment is marked failed - the other 11 are skipped
|
||||
// entirely without burning more quota.
|
||||
expect($stats['updated'])->toBe(0);
|
||||
expect($stats['failed'])->toBe(1);
|
||||
expect($stats['results'])->toHaveCount(1);
|
||||
expect($stats['results'][0])
|
||||
->toHaveKey('rate_limited', true)
|
||||
->toHaveKey('success', false);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('parses an HTTP-date Retry-After header into a positive integer', function () {
|
||||
$retryDate = gmdate('D, d M Y H:i:s', time() + 300).' GMT';
|
||||
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([], 429, ['Retry-After' => $retryDate]),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->trackShipment('00340434292135100148');
|
||||
|
||||
expect($result['retry_after'])
|
||||
->toBeInt()
|
||||
->toBeGreaterThan(250)
|
||||
->toBeLessThanOrEqual(310);
|
||||
});
|
||||
|
||||
it('activates a quota pause from the Retry-After header and short-circuits subsequent calls', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([], 429, ['Retry-After' => '600']),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$first = $service->trackShipment('00340434292135100100');
|
||||
|
||||
expect($first['rate_limited'])->toBeTrue();
|
||||
expect(DhlTrackingService::isQuotaPaused())->toBeTrue();
|
||||
expect(DhlTrackingService::getQuotaPausedUntil())
|
||||
->not->toBeNull()
|
||||
->and(DhlTrackingService::getQuotaPausedUntil()->isFuture())->toBeTrue();
|
||||
|
||||
// Second call - must NOT hit DHL at all, must reuse the cached pause.
|
||||
$second = $service->trackShipment('00340434292135100101');
|
||||
|
||||
expect($second)
|
||||
->toHaveKey('rate_limited', true)
|
||||
->toHaveKey('http_status', 429)
|
||||
->toHaveKey('paused_until');
|
||||
expect($second['message'])->toContain('Lokale Quota-Pause aktiv bis');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('uses the default pause window when DHL does not send a Retry-After header', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Too Many Requests'], 429),
|
||||
]);
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$service->trackShipment('00340434292135100100');
|
||||
|
||||
$until = DhlTrackingService::getQuotaPausedUntil();
|
||||
|
||||
expect($until)->not->toBeNull();
|
||||
// Default is one hour - allow a small jitter for clock drift inside the test run.
|
||||
$secondsAhead = $until->diffInSeconds(now(), false) * -1;
|
||||
expect($secondsAhead)
|
||||
->toBeGreaterThan(3500)
|
||||
->toBeLessThanOrEqual(3700);
|
||||
});
|
||||
|
||||
it('skips the entire updateTrackingBatch when a quota pause is already cached', function () {
|
||||
DhlTrackingService::pauseQuota(900);
|
||||
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/*' => Http::response(['title' => 'Should never be called'], 500),
|
||||
]);
|
||||
|
||||
/**
|
||||
* @var Collection<int, DhlShipment&\Mockery\MockInterface>
|
||||
*/
|
||||
$shipments = collect(range(1, 25))->map(function (int $i) {
|
||||
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||
$shipment->id = $i;
|
||||
$shipment->dhl_shipment_no = '003404342921351002'.str_pad((string) $i, 2, '0', STR_PAD_LEFT);
|
||||
$shipment->status = 'in_transit';
|
||||
$shipment->shouldNotReceive('update');
|
||||
|
||||
return $shipment;
|
||||
});
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$stats = $service->updateTrackingBatch($shipments);
|
||||
|
||||
expect($stats['updated'])->toBe(0);
|
||||
expect($stats['failed'])->toBe(25);
|
||||
expect($stats['results'])->toHaveCount(25);
|
||||
foreach ($stats['results'] as $row) {
|
||||
expect($row['rate_limited'])->toBeTrue();
|
||||
expect($row['paused_until'])->toBeString();
|
||||
}
|
||||
|
||||
// Crucially: NO HTTP call must have been sent.
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('clears the quota pause via clearQuotaPause', function () {
|
||||
DhlTrackingService::pauseQuota(600);
|
||||
expect(DhlTrackingService::isQuotaPaused())->toBeTrue();
|
||||
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
expect(DhlTrackingService::isQuotaPaused())->toBeFalse();
|
||||
expect(DhlTrackingService::getQuotaPausedUntil())->toBeNull();
|
||||
});
|
||||
|
||||
it('redacts the api key in the rate-limit log payload', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([], 429),
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Log::spy();
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$service->trackShipment('00340434292135100148');
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldHaveReceived('error')
|
||||
->withArgs(function (string $message, array $context) {
|
||||
return str_contains($message, 'rate-limited')
|
||||
&& ($context['api_key_suffix'] ?? null) === '***9999'
|
||||
&& ! str_contains(json_encode($context), 'cached-test-api-key');
|
||||
});
|
||||
});
|
||||
119
tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php
Normal file
119
tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Services\DhlTrackingService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$reflection = new ReflectionClass(SettingController::class);
|
||||
$property = $reflection->getProperty('cachedDhlConfig');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, [
|
||||
'api_key' => 'cached-test-api-key-1234',
|
||||
'use_queue' => false,
|
||||
]);
|
||||
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
DhlTrackingService::setCallIntervalSeconds(0);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
SettingController::flushDhlConfigCache();
|
||||
DhlTrackingService::clearQuotaPause();
|
||||
DhlTrackingService::setCallIntervalSeconds(5);
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return DhlShipment&\Mockery\MockInterface
|
||||
*/
|
||||
function makeFakeShipment(int $id, string $trackingNumber, ?Carbon\Carbon $lastTrackedAt = null)
|
||||
{
|
||||
/** @var DhlShipment&\Mockery\MockInterface $shipment */
|
||||
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||
$shipment->id = $id;
|
||||
$shipment->dhl_shipment_no = $trackingNumber;
|
||||
$shipment->last_tracked_at = $lastTrackedAt;
|
||||
|
||||
return $shipment;
|
||||
}
|
||||
|
||||
it('does not update last_tracked_at when the Unified API returns 401', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
$shipment = makeFakeShipment(123, '00340434292135100148', now()->subDays(2));
|
||||
|
||||
// The sync path must never call ->update() on the shipment when an auth
|
||||
// error occurs. Asserting "never called" is the strongest contract we
|
||||
// can express here without a database.
|
||||
$shipment->shouldNotReceive('update');
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->updateTracking($shipment, ['auto_retrack' => false]);
|
||||
|
||||
expect($result)
|
||||
->toHaveKey('success', false)
|
||||
->toHaveKey('auth_error', true)
|
||||
->toHaveKey('http_status', 401);
|
||||
});
|
||||
|
||||
it('does update last_tracked_at when DHL says the shipment is not found', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response(['shipments' => []], 200),
|
||||
]);
|
||||
|
||||
$shipment = makeFakeShipment(123, '00340434292135100148');
|
||||
|
||||
$shipment->shouldReceive('update')
|
||||
->once()
|
||||
->withArgs(function (array $payload) {
|
||||
return array_keys($payload) === ['last_tracked_at']
|
||||
&& ! array_key_exists('tracking_status', $payload)
|
||||
&& ! array_key_exists('status', $payload);
|
||||
})
|
||||
->andReturnTrue();
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$result = $service->updateTracking($shipment, ['auto_retrack' => false]);
|
||||
|
||||
expect($result)
|
||||
->toHaveKey('success', false)
|
||||
->toHaveKey('auth_error', false);
|
||||
});
|
||||
|
||||
it('stops the batch tracker on the first auth error to avoid burning the API quota', function () {
|
||||
Http::fake([
|
||||
'api-eu.dhl.com/track/shipments*' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
$shipments = new Collection([
|
||||
makeFakeShipment(1, 'A1'),
|
||||
makeFakeShipment(2, 'B2'),
|
||||
makeFakeShipment(3, 'C3'),
|
||||
]);
|
||||
|
||||
foreach ($shipments as $shipment) {
|
||||
$shipment->shouldNotReceive('update');
|
||||
}
|
||||
|
||||
$service = new DhlTrackingService;
|
||||
$stats = $service->updateTrackingBatch($shipments);
|
||||
|
||||
// Since Phase 13 the batch tracker fires one HTTP call per shipment and
|
||||
// aborts on the *first* auth error. So exactly one shipment is marked
|
||||
// failed (the one we tried), the other two are skipped entirely.
|
||||
expect($stats)
|
||||
->toHaveKey('updated', 0)
|
||||
->toHaveKey('failed', 1);
|
||||
|
||||
expect($stats['results'])->toHaveCount(1);
|
||||
expect($stats['results'][0])
|
||||
->toHaveKey('success', false)
|
||||
->toHaveKey('auth_error', true);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
48
tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php
Normal file
48
tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Acme\Dhl\Services\ReturnsService;
|
||||
use Acme\Dhl\Support\DhlClient;
|
||||
|
||||
function invokeReturnsServiceConvert(string $method, $argument)
|
||||
{
|
||||
$service = new ReturnsService(new DhlClient('https://example.test', null, null, null));
|
||||
|
||||
$reflection = (new ReflectionClass(ReturnsService::class))->getMethod($method);
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
return $reflection->invoke($service, $argument);
|
||||
}
|
||||
|
||||
it('converts known ISO-2 country codes to DHL ISO-3 via the resolver', function (string $input, string $expected) {
|
||||
expect(invokeReturnsServiceConvert('convertCountryCode', $input))->toBe($expected);
|
||||
})->with([
|
||||
'germany' => ['DE', 'DEU'],
|
||||
'austria' => ['AT', 'AUT'],
|
||||
'switzerland' => ['CH', 'CHE'],
|
||||
'spain' => ['ES', 'ESP'],
|
||||
'germany lowercase' => ['de', 'DEU'],
|
||||
'germany already iso-3' => ['DEU', 'DEU'],
|
||||
]);
|
||||
|
||||
it('throws on unsupported country codes instead of silently using DEU', function () {
|
||||
invokeReturnsServiceConvert('convertCountryCode', 'XX');
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('normalizes addresses back to ISO-2 via the resolver', function () {
|
||||
$converted = invokeReturnsServiceConvert('convertAddressFor2LetterCountry', [
|
||||
'name' => 'Test',
|
||||
'country' => 'AUT',
|
||||
]);
|
||||
|
||||
expect($converted['country'])->toBe('AT');
|
||||
});
|
||||
|
||||
it('throws when normalizing an address with an unsupported country', function () {
|
||||
invokeReturnsServiceConvert('convertAddressFor2LetterCountry', ['country' => 'ZZ']);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('keeps the address unchanged when the country key is missing', function () {
|
||||
$converted = invokeReturnsServiceConvert('convertAddressFor2LetterCountry', ['name' => 'Test']);
|
||||
|
||||
expect($converted)->toBe(['name' => 'Test']);
|
||||
});
|
||||
50
tests/Unit/Dhl/ShippingServiceParseAddressTest.php
Normal file
50
tests/Unit/Dhl/ShippingServiceParseAddressTest.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Acme\Dhl\Services\ShippingService;
|
||||
use Acme\Dhl\Support\DhlClient;
|
||||
|
||||
function invokeParseAddressFields(array $address): array
|
||||
{
|
||||
$service = new ShippingService(new DhlClient('https://example.test', null, null, null));
|
||||
|
||||
$reflection = (new ReflectionClass(ShippingService::class))->getMethod('parseAddressFields');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
return $reflection->invoke($service, $address);
|
||||
}
|
||||
|
||||
it('keeps an explicit house number unchanged', function () {
|
||||
$address = invokeParseAddressFields([
|
||||
'street' => 'Musterstrasse',
|
||||
'houseNumber' => '42a',
|
||||
]);
|
||||
|
||||
expect($address['street'])->toBe('Musterstrasse')
|
||||
->and($address['houseNumber'])->toBe('42a');
|
||||
});
|
||||
|
||||
it('extracts the house number from a combined street field', function () {
|
||||
$address = invokeParseAddressFields([
|
||||
'street' => 'Musterstrasse 42a',
|
||||
'houseNumber' => '',
|
||||
]);
|
||||
|
||||
expect($address['street'])->toBe('Musterstrasse')
|
||||
->and($address['houseNumber'])->toBe('42a');
|
||||
});
|
||||
|
||||
it('throws when the street contains no parseable house number instead of defaulting to 1', function () {
|
||||
invokeParseAddressFields([
|
||||
'street' => 'Postfach',
|
||||
'houseNumber' => '',
|
||||
]);
|
||||
})->throws(InvalidArgumentException::class, 'Hausnummer fehlt');
|
||||
|
||||
it('does not throw when neither street nor house number are provided', function () {
|
||||
$address = invokeParseAddressFields([
|
||||
'street' => '',
|
||||
'houseNumber' => '',
|
||||
]);
|
||||
|
||||
expect($address)->toBe(['street' => '', 'houseNumber' => '']);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue