mivita/tests/Unit/Dhl/DhlTrackingRateLimitTest.php

220 lines
7.7 KiB
PHP

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