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
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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue