Files
myep/tests/ContentFeed/ContentFeedClientTest.php
T

258 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\ContentFeed;
use App\ContentFeed\ContentFeedClient;
use App\ContentFeed\Exception\ContentFeedStoppedException;
use App\ContentFeed\Exception\ContentFeedUnavailableException;
use App\ContentFeed\Exception\ContentRequestFailedException;
use App\ContentFeed\Exception\PagePhaseEndedException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* The load rules are a contract with the maintainers of the TYPO3 site, so each one is
* pinned here.
*/
class ContentFeedClientTest extends TestCase
{
private const BASE = 'https://www.ep-reisen.de/';
private MockClock $clock;
protected function setUp(): void
{
$this->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<string, array{MockResponse}>
*/
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('<article><h1>A</h1></article>'),
]);
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('<article><h1>A</h1></article>', $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('<p>a</p>'), new MockResponse('<p>b</p>'), new MockResponse('<p>c</p>')]);
$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('<p>a</p>', ['total_time' => 2.5]), new MockResponse('<p>b</p>')]);
$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('<p>a</p>', ['total_time' => 3.0]),
new MockResponse('<p>b</p>', ['total_time' => 3.0]),
new MockResponse('<p>c</p>', ['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<MockResponse|callable> $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<MockResponse> $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));
}
}