feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed
This commit is contained in:
1 parent
ff7ddf7a9e
commit
1b4384e3f3
52 files changed
+5117
-4
No files matched your search
@@ -0,0 +1,257 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user