clock = new MockClock('2026-10-08 10:00:00'); } public function testManifestIsParsedAndSendsTheApiKey(): void { $requests = []; $client = $this->client([ function (string $method, string $url, array $options) use (&$requests): MockResponse { $requests[] = [$url, $options['normalized_headers']['x-api-key'][0] ?? null]; return $this->manifestResponse(); }, ]); $manifest = $client->manifest(); self::assertSame([['https://www.ep-reisen.de/api/content/manifest', 'X-Api-Key: secret']], $requests); self::assertCount(2, $manifest->items); self::assertSame(['www.ep-reisen.de', 'snowzone.net'], $manifest->siteHosts); self::assertSame('2026-10-08 08:15:00 UTC', $manifest->generatedAt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s T')); } /** * @return iterable */ public static function unusableManifests(): iterable { yield 'server error' => [new MockResponse('', ['http_code' => 500])]; yield 'forbidden' => [new MockResponse('', ['http_code' => 403])]; yield 'invalid json' => [new MockResponse('{"items":')]; yield 'empty items' => [new MockResponse('{"generatedAt":"2026-10-08T10:15:00+02:00","sites":[],"items":[]}')]; yield 'malformed item' => [new MockResponse('{"generatedAt":"2026-10-08T10:15:00+02:00","sites":[],"items":[{"type":"page"}]}')]; } /** * @dataProvider unusableManifests */ #[\PHPUnit\Framework\Attributes\DataProvider('unusableManifests')] public function testAnUnusableManifestStopsTheRun(MockResponse $response): void { $this->expectException(ContentFeedStoppedException::class); $this->client([$response])->manifest(); } public function testMissingApiKeyStopsWithoutARequest(): void { $this->expectException(ContentFeedStoppedException::class); $this->expectExceptionMessage('APP_CMS_API_KEY'); (new ContentFeedClient(new MockHttpClient([]), $this->clock, new NullLogger(), self::BASE, ''))->manifest(); } public function test503EndsTheRunWithRetryAfter(): void { try { $this->client([new MockResponse('', ['http_code' => 503, 'response_headers' => ['Retry-After' => '3600']])])->manifest(); self::fail('Expected the run to stop'); } catch (ContentFeedUnavailableException $e) { self::assertSame($this->clock->now()->getTimestamp() + 3600, $e->retryAfter?->getTimestamp()); } } public function testRecordsAreKeyedByUidFromTheEnvelope(): void { $client = $this->client([new MockResponse('{"type":"product","items":[{"uid":412,"name":"A"},{"uid":413,"name":"B"}]}')]); $records = $client->records('product', [412, 413, 414]); self::assertSame([412, 413], array_keys($records)); } public function testARecordsResponseWithoutTheEnvelopeFailsTheBatchInsteadOfDeleting(): void { $this->expectException(ContentRequestFailedException::class); $this->client([new MockResponse('[{"uid":412}]')])->records('product', [412]); } public function testThreeConsecutiveServerErrorsOrTimeoutsStopTheRun(): void { $client = $this->client([ new MockResponse('', ['http_code' => 500]), new MockResponse([new TransportException('timeout')]), new MockResponse('', ['http_code' => 502]), ]); foreach ([1, 2] as $attempt) { try { $client->records('product', [$attempt]); self::fail('Expected the batch to fail'); } catch (ContentRequestFailedException) { } } $this->expectException(ContentFeedStoppedException::class); $client->records('product', [3]); } public function testASuccessResetsTheFailureCount(): void { $client = $this->client([ new MockResponse('', ['http_code' => 500]), new MockResponse('', ['http_code' => 500]), new MockResponse('{"type":"product","items":[]}'), new MockResponse('', ['http_code' => 500]), ]); $failures = 0; foreach ([1, 2, 3, 4] as $uid) { try { $client->records('product', [$uid]); } catch (ContentRequestFailedException) { ++$failures; } } self::assertSame(3, $failures); } public function testASinglePageErrorIsAFailureOfThatPageOnly(): void { $client = $this->clientWithManifest([ new MockResponse('', ['http_code' => 404]), new MockResponse('', ['http_code' => 500]), new MockResponse('

A

'), ]); self::assertSame(404, $client->page('https://www.ep-reisen.de/a?type=1730')->failedStatus); self::assertSame(500, $client->page('https://www.ep-reisen.de/b?type=1730')->failedStatus); self::assertSame('

A

', $client->page('https://www.ep-reisen.de/c?type=1730')->html); } public function testAPageTimeoutIsTransient(): void { $client = $this->clientWithManifest([new MockResponse([new TransportException('timeout')])]); self::assertTrue($client->page('https://www.ep-reisen.de/a?type=1730')->transient); } public function testPagesAreFetchedAtMostOncePerSecond(): void { $client = $this->clientWithManifest([new MockResponse('

a

'), new MockResponse('

b

'), new MockResponse('

c

')]); $start = $this->clock->now(); $client->page('https://www.ep-reisen.de/a?type=1730'); $client->page('https://www.ep-reisen.de/b?type=1730'); $client->page('https://www.ep-reisen.de/c?type=1730'); self::assertSame(2, $this->clock->now()->getTimestamp() - $start->getTimestamp()); } public function testASlowResponseDelaysTheNextRequestByFiveSeconds(): void { $client = $this->clientWithManifest([new MockResponse('

a

', ['total_time' => 2.5]), new MockResponse('

b

')]); $start = $this->clock->now(); $client->page('https://www.ep-reisen.de/a?type=1730'); $client->page('https://www.ep-reisen.de/b?type=1730'); self::assertSame(5, $this->clock->now()->getTimestamp() - $start->getTimestamp()); } public function testThreeSlowPagesInARowEndThePagePhase(): void { $client = $this->clientWithManifest([ new MockResponse('

a

', ['total_time' => 3.0]), new MockResponse('

b

', ['total_time' => 3.0]), new MockResponse('

c

', ['total_time' => 3.0]), ]); $client->page('https://www.ep-reisen.de/a?type=1730'); $client->page('https://www.ep-reisen.de/b?type=1730'); $this->expectException(PagePhaseEndedException::class); $client->page('https://www.ep-reisen.de/c?type=1730'); } public function testTheKeyIsNeverSentToAHostOutsideTheManifestSites(): void { $client = $this->clientWithManifest([]); $result = $client->page('https://evil.example/a?type=1730'); self::assertFalse($result->isOk()); self::assertFalse($result->transient); } /** * @param list $responses */ private function client(array $responses): ContentFeedClient { $client = new ContentFeedClient(new MockHttpClient($responses, self::BASE), $this->clock, new NullLogger(), self::BASE, 'secret'); $client->startRun(); return $client; } /** * A client that has read the manifest, so it knows the sites' hosts. * * @param list $responses */ private function clientWithManifest(array $responses): ContentFeedClient { $client = $this->client(array_merge([$this->manifestResponse()], $responses)); $client->manifest(); return $client; } private function manifestResponse(): MockResponse { return new MockResponse(json_encode([ 'generatedAt' => '2026-10-08T10:15:00+02:00', 'sites' => [ ['identifier' => 'ep-reisen', 'base' => 'https://www.ep-reisen.de/'], ['identifier' => 'snowzone', 'base' => 'https://snowzone.net/'], ], 'items' => [ ['type' => 'page', 'uid' => 1, 'language' => 'de', 'site' => 'ep-reisen', 'url' => 'https://www.ep-reisen.de/a', 'contentUrl' => 'https://www.ep-reisen.de/a?type=1730', 'modified' => '2026-09-30T14:02:11+02:00'], ['type' => 'product', 'uid' => 412, 'language' => 'de', 'site' => 'ep-reisen', 'url' => null, 'modified' => '2026-09-12T09:40:00+02:00'], ], ], \JSON_THROW_ON_ERROR)); } }