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'); }); });