258 lines
9.6 KiB
PHP
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));
|
|
}
|
|
}
|