@if(false)
+ @php
+ $publicTrackingUrl = \App\Domain\EarlyDomainParser::getMainUrl().'/tracking';
+ @endphp
{{ $shipment->dhl_shipment_no }}
-
Verfolgen
diff --git a/resources/views/public/tracking.blade.php b/resources/views/public/tracking.blade.php
index 4aa66fa..10c07dc 100644
--- a/resources/views/public/tracking.blade.php
+++ b/resources/views/public/tracking.blade.php
@@ -212,20 +212,37 @@ $(document).ready(function() {
});
});
+ // Escape HTML special characters so DHL-/DB-derived strings can never
+ // execute JavaScript even if jQuery's .html() is used.
+ function escapeTrackingHtml(value) {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
// Show tracking results
function showTrackingResult(data) {
var statusBadge = getStatusBadge(data.status);
- var trackingStatusInfo = data.tracking_status ?
- `
DHL Status: ${data.tracking_status}
` : '';
- var lastTrackedInfo = data.last_tracked_at ?
- `
Zuletzt aktualisiert: ${data.last_tracked_at}
` : '';
-
+ var trackingStatusInfo = data.tracking_status ?
+ `
DHL Status: ${escapeTrackingHtml(data.tracking_status)}
` : '';
+ var lastTrackedInfo = data.last_tracked_at ?
+ `
Zuletzt aktualisiert: ${escapeTrackingHtml(data.last_tracked_at)}
` : '';
+
+ var trackingNumberEscaped = escapeTrackingHtml(data.tracking_number);
+ var trackingNumberUrlEncoded = encodeURIComponent(data.tracking_number ?? '');
+
var html = `
- ${data.tracking_number}
+ ${trackingNumberEscaped}
Status: ${statusBadge}
${trackingStatusInfo}
@@ -239,7 +256,7 @@ $(document).ready(function() {
Die Informationen werden regelmäßig aktualisiert.
Für detaillierte Tracking-Informationen besuchen Sie die
-
DHL Website
@@ -248,7 +265,7 @@ $(document).ready(function() {
`;
-
+
$('#tracking-content').html(html);
$('#tracking-results').show();
@@ -267,7 +284,9 @@ $(document).ready(function() {
// Show error message
function showError(message) {
- $('#error-message .alert p').html(message);
+ // Use .text() instead of .html() so error strings from the server
+ // can never inject HTML.
+ $('#error-message .alert p').text(message);
$('#error-message').show();
// Smooth scroll to error
@@ -290,7 +309,7 @@ $(document).ready(function() {
function getStatusBadge(status) {
var badgeClass = '';
var text = status;
-
+
switch(status) {
case 'pending':
badgeClass = 'badge-warning';
@@ -320,8 +339,11 @@ $(document).ready(function() {
default:
badgeClass = 'badge-light';
}
-
- return `
${text}`;
+
+ // Both the fallback `text` (raw status) and the class are escaped so
+ // unmapped DHL status codes can never inject HTML or break out of the
+ // class attribute.
+ return `
${escapeTrackingHtml(text)}`;
}
// Get status icon
diff --git a/routes/domains/crm.php b/routes/domains/crm.php
index ec4307d..b09b46e 100644
--- a/routes/domains/crm.php
+++ b/routes/domains/crm.php
@@ -299,9 +299,14 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/shipment/{shipment}/download-label', 'DhlShipmentController@downloadLabel')->name('admin.dhl.download-label');
Route::post('/batch-action', 'DhlShipmentController@batchAction')->name('admin.dhl.batch-action');
Route::post('/test-login', 'DhlShipmentController@testLogin')->name('admin.dhl.test_login');
- Route::get('/public/track', 'DhlShipmentController@track')->name('public.tracking');
});
+ // The previously registered `public.tracking` route lived inside this
+ // admin group, which made it auth/admin protected and therefore not
+ // actually public. The real public tracking page is defined on the
+ // main domain in `routes/domains/main.php`. It is intentionally not
+ // duplicated here.
+
// products attributes
Route::get('/admin/product/attributes', 'AttributeController@index')->name('admin_product_attributes');
Route::post('/admin/product/attribute/store', 'AttributeController@store')->name('admin_product_attribute_store');
diff --git a/tests/Unit/Dhl/CreateShipmentJobSerializationTest.php b/tests/Unit/Dhl/CreateShipmentJobSerializationTest.php
new file mode 100644
index 0000000..6891e03
--- /dev/null
+++ b/tests/Unit/Dhl/CreateShipmentJobSerializationTest.php
@@ -0,0 +1,41 @@
+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();
+});
diff --git a/tests/Unit/Dhl/DhlConfigCachingTest.php b/tests/Unit/Dhl/DhlConfigCachingTest.php
new file mode 100644
index 0000000..a404b8d
--- /dev/null
+++ b/tests/Unit/Dhl/DhlConfigCachingTest.php
@@ -0,0 +1,47 @@
+ '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();
+});
diff --git a/tests/Unit/Dhl/DhlModalAuthorizationTest.php b/tests/Unit/Dhl/DhlModalAuthorizationTest.php
new file mode 100644
index 0000000..f24c2b3
--- /dev/null
+++ b/tests/Unit/Dhl/DhlModalAuthorizationTest.php
@@ -0,0 +1,47 @@
+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();
+});
diff --git a/tests/Unit/Dhl/DhlProductResolverTest.php b/tests/Unit/Dhl/DhlProductResolverTest.php
index d65525f..8724c4a 100644
--- a/tests/Unit/Dhl/DhlProductResolverTest.php
+++ b/tests/Unit/Dhl/DhlProductResolverTest.php
@@ -1,6 +1,9 @@
'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;
diff --git a/tests/Unit/Dhl/DhlRouteRegistrationTest.php b/tests/Unit/Dhl/DhlRouteRegistrationTest.php
new file mode 100644
index 0000000..d9f97d5
--- /dev/null
+++ b/tests/Unit/Dhl/DhlRouteRegistrationTest.php
@@ -0,0 +1,15 @@
+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[\'"]\)/');
+});
diff --git a/tests/Unit/Dhl/DhlSanitizeLoggingTest.php b/tests/Unit/Dhl/DhlSanitizeLoggingTest.php
new file mode 100644
index 0000000..96ea1dc
--- /dev/null
+++ b/tests/Unit/Dhl/DhlSanitizeLoggingTest.php
@@ -0,0 +1,116 @@
+ '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');
+});
diff --git a/tests/Unit/Dhl/DhlTrackingAuthErrorTest.php b/tests/Unit/Dhl/DhlTrackingAuthErrorTest.php
new file mode 100644
index 0000000..e4700be
--- /dev/null
+++ b/tests/Unit/Dhl/DhlTrackingAuthErrorTest.php
@@ -0,0 +1,108 @@
+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');
+ });
+});
diff --git a/tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php b/tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php
new file mode 100644
index 0000000..ef37e62
--- /dev/null
+++ b/tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php
@@ -0,0 +1,132 @@
+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();
+});
diff --git a/tests/Unit/Dhl/DhlTrackingRateLimitTest.php b/tests/Unit/Dhl/DhlTrackingRateLimitTest.php
new file mode 100644
index 0000000..96ff126
--- /dev/null
+++ b/tests/Unit/Dhl/DhlTrackingRateLimitTest.php
@@ -0,0 +1,220 @@
+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
+ */
+ $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
+ */
+ $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');
+ });
+});
diff --git a/tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php b/tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php
new file mode 100644
index 0000000..362266c
--- /dev/null
+++ b/tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php
@@ -0,0 +1,119 @@
+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);
+});
diff --git a/tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php b/tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php
new file mode 100644
index 0000000..345dfc0
--- /dev/null
+++ b/tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php
@@ -0,0 +1,48 @@
+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']);
+});
diff --git a/tests/Unit/Dhl/ShippingServiceParseAddressTest.php b/tests/Unit/Dhl/ShippingServiceParseAddressTest.php
new file mode 100644
index 0000000..9a664ed
--- /dev/null
+++ b/tests/Unit/Dhl/ShippingServiceParseAddressTest.php
@@ -0,0 +1,50 @@
+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' => '']);
+});