feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed

This commit is contained in:
fromme committed 2026-10-08 11:46:58 +02:00
1 parent ff7ddf7a9e
commit 1b4384e3f3
52 files changed
+5117 -4

No files matched your search

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Tests\Chatbot\Search;
use App\Chatbot\Search\ContentSearchQuery;
use App\Chatbot\Search\Excerpt;
use PHPUnit\Framework\TestCase;
class ContentSearchQueryTest extends TestCase
{
public function testDropsGermanStopwordsShortTokensAndOperators(): void
{
self::assertSame(
'skipass* val* thorens* inklusive*',
ContentSearchQuery::booleanQuery('Ist im Skipass bei "Val Thorens" alles +inklusive? Ja!'),
);
}
public function testTermsAreUniqueAndLowerCased(): void
{
self::assertSame(['gepäck', 'snowboard'], ContentSearchQuery::terms('Gepäck GEPÄCK Snowboard'));
}
public function testNothingSearchableLeftIsNull(): void
{
self::assertNull(ContentSearchQuery::booleanQuery('Was ist das?'));
self::assertNull(ContentSearchQuery::booleanQuery('***'));
}
public function testExcerptIsTheWholeTextWhenShort(): void
{
self::assertSame('Kurzer Text', Excerpt::around("Kurzer\n\nText", ['text']));
}
public function testExcerptCentresOnTheFirstMatch(): void
{
$text = str_repeat('Füllwort ', 200).'Der Skipass ist inklusive. '.str_repeat('Nachwort ', 200);
$excerpt = Excerpt::around($text, ['skipass']);
self::assertStringContainsString('Der Skipass ist inklusive.', $excerpt);
self::assertStringStartsWith('…', $excerpt);
self::assertStringEndsWith('…', $excerpt);
self::assertLessThanOrEqual(Excerpt::LENGTH + 2, mb_strlen($excerpt));
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Tests\Chatbot;
use App\Chatbot\TripCatalog;
use App\Entity\ContentDocument;
use App\Enum\ContentFetchState;
use App\Model\Chatbot\TripCriteria;
use App\Model\Export;
use App\Repository\ContentDocumentRepository;
use App\Service\ProductExportBuilder;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
class TripCatalogTest extends TestCase
{
public function testSearchReturnsUpcomingDatesWithTheLowestBookablePrice(): void
{
$result = $this->catalog([
$this->row('VTH', 1, '2026-09-01', 'HOT1', [$this->room(100)]),
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500), $this->room(450, status: 'Anfrage'), $this->room(99, status: 'Buchungsstop')]),
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520, status: 'Anfrage')]),
])->search(new TripCriteria());
self::assertCount(1, $result['results']);
$trip = $result['results'][0];
self::assertSame('VTH', $trip['code']);
self::assertSame('Val Thorens Apartments', $trip['name']);
self::assertSame('https://www.ep-reisen.de/val-thorens/', $trip['url']);
self::assertSame(['country' => 'Frankreich'], $trip['destination']);
self::assertSame(['Hotel Eins'], $trip['hotels']);
self::assertSame([
['dateFrom' => '2026-12-05', 'dateTo' => '2026-12-12', 'priceFrom' => 450.0, 'availability' => 'frei'],
['dateFrom' => '2027-01-09', 'dateTo' => '2027-01-16', 'priceFrom' => 520.0, 'availability' => 'auf Anfrage'],
], $trip['nextDates']);
self::assertSame('2026-10-08T06:00:00+00:00', $result['dataAsOf']);
}
public function testPersonsCountsPlacesAcrossBookableRoomsOnly(): void
{
$rows = [
// 2 rooms x 2 pax + 1 x 1 pax = 5 places; the stopped room's 10 do not count.
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(400, available: 2, maxPax: 2), $this->room(300, available: 1, maxPax: 1), $this->room(200, available: 10, maxPax: 1, status: 'Buchungsstop')]),
];
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(persons: 5))['results']);
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(persons: 6))['results']);
}
public function testDatePeriodMatchesOnOverlapAndMaxPriceOnTheLowestPrice(): void
{
$rows = [$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450), $this->room(700)])];
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-12'), dateTo: new \DateTimeImmutable('2026-12-31')))['results']);
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-13')))['results']);
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(maxPrice: 450))['results']);
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(maxPrice: 449.99))['results']);
}
public function testCountryAndConceptAreResolvedThroughTheContentRecords(): void
{
$rows = [$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)])];
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(country: 'frankreich', concept: 'ski'))['results']);
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(country: 'Österreich'))['results']);
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(concept: 'party'))['results']);
}
public function testGetJoinsHotelsOnTheirAliasesAndPreferTheOwner(): void
{
$trip = $this->catalog([
$this->row('VTH', 2, '2026-12-05', 'HOT1ALIAS', [$this->room(450)]),
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(380, status: 'Buchungsstop')]),
])->get('vth', 'HOT1');
self::assertNotNull($trip);
self::assertSame(['Hotel Eins'], array_column($trip['hotels'], 'name'));
self::assertCount(1, $trip['dates']);
self::assertSame('Hotel Eins', $trip['dates'][0]['hotels'][0]['hotel']);
self::assertSame([['room' => 'Zimmer', 'board' => 'HP', 'nights' => 7, 'price' => 450, 'availability' => 'frei']], $trip['dates'][0]['hotels'][0]['rooms']);
self::assertSame([['city' => 'Frankfurt', 'place' => 'Flughafen', 'departure' => '2026-12-04 23:30', 'surcharge' => 5.9]], $trip['dates'][0]['pickups']);
self::assertArrayNotHasKey('guide', $trip['dates'][0]);
}
public function testProductsHiddenFromTheWebsiteSearchAreNotOfferedButCanBeNamed(): void
{
$rows = [$this->row('GRP', 2, '2026-12-05', 'HOT1', [$this->room(300)])];
self::assertSame([], $this->catalog($rows)->search(new TripCriteria())['results']);
self::assertSame('GRP', $this->catalog($rows)->get('GRP')['code'] ?? null);
}
public function testGetIsNullForACodeWithoutAPublishedProduct(): void
{
self::assertNull($this->catalog([$this->row('XYZ', 2, '2026-12-05', 'HOT1', [$this->room(1)])])->get('XYZ'));
}
/**
* @param list<array<string, mixed>> $rows
*/
private function catalog(array $rows): TripCatalog
{
$export = $this->createStub(ProductExportBuilder::class);
$export->method('build')->willReturn(new Export($rows, 0, 0, new \DateTimeImmutable('2026-10-08 09:00:00+00:00'), new \DateTimeImmutable('2026-10-08 06:00:00+00:00')));
$records = [];
foreach ([
$this->record('product', 1, 'Val Thorens Apartments', 'https://www.ep-reisen.de/val-thorens/', ['code' => 'VTH', 'countryUid' => 2, 'conceptUid' => 7, 'hotelUids' => [10], 'daytrip' => false, 'bookable' => true]),
$this->record('product', 2, 'Gruppenreise Uni X', 'https://www.ep-reisen.de/gruppe/', ['code' => 'GRP', 'countryUid' => 2, 'searchable' => false, 'bookable' => true]),
$this->record('hotel', 10, 'Hotel Eins', null, ['code' => 'HOT1', 'codes' => ['HOT1', 'HOT1ALIAS', 'SHARED'], 'facts' => ['wifi' => true, 'sauna' => null]]),
$this->record('hotel', 11, 'Hotel Zwei', null, ['code' => 'SHARED', 'codes' => ['HOT2']]),
$this->record('country', 2, 'Frankreich', null, ['code' => 'FR']),
$this->record('concept', 7, 'Skiurlaub ohne Programm', null, ['code' => 'ski']),
] as $record) {
$records[$record->getRecordRef()] = $record;
}
$repository = $this->createStub(ContentDocumentRepository::class);
$repository->method('findRecords')->willReturn($records);
$repository->method('matchRecords')->willReturn([]);
return new TripCatalog($export, $repository, new MockClock('2026-10-08 10:00:00'), new NullLogger());
}
/**
* @param array<string, mixed> $data
*/
private function record(string $type, int $uid, string $title, ?string $url, array $data): ContentDocument
{
return (new ContentDocument($type, $uid, 'de', new \DateTimeImmutable()))
->setTitle($title)
->setUrl($url)
->setSite(null === $url ? null : 'ep-reisen')
->setData($data + ['uid' => $uid])
->setBodyMd('# '.$title)
->markFetched(ContentFetchState::Ok, new \DateTimeImmutable());
}
/**
* @param list<array<string, mixed>> $rooms
*
* @return array<string, mixed>
*/
private function row(string $code, int $dateId, string $from, string $hotelCode, array $rooms): array
{
$dateFrom = new \DateTimeImmutable($from);
return [
'product' => ['code' => $code, 'busProId' => 1],
'date' => ['code' => $code.$dateId, 'busProId' => $dateId, 'from' => $from, 'to' => $dateFrom->modify('+7 days')->format('Y-m-d'), 'type' => 'F'],
'hotel' => ['code' => $hotelCode, 'busProId' => crc32($hotelCode)],
'rooms' => $rooms,
'pickups' => [
['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => 5.9, 'datetime' => $dateFrom->modify('-1 day')->format('Y-m-d').'T23:30:00'],
['busProId' => 0, 'code' => null, 'city' => null, 'street' => null, 'price' => null, 'datetime' => null],
],
'guide' => ['name' => 'Anna', 'phone' => '0170'],
];
}
/**
* @return array<string, mixed>
*/
private function room(int $price, int $available = 5, int $maxPax = 2, string $status = 'Frei'): array
{
return ['code' => 'DZ', 'label' => 'Zimmer', 'minPax' => 1, 'maxPax' => $maxPax, 'nights' => 7, 'price' => $price, 'available' => $available, 'status' => $status, 'boardCode' => 'HP'];
}
}
+257
View File
@@ -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));
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Tests\ContentFeed;
use App\Chatbot\TripCatalog;
use App\ContentFeed\ContentFeedClient;
use App\ContentFeed\ContentSyncer;
use App\ContentFeed\DocumentBodyBuilder;
use App\ContentFeed\Exception\ContentFeedStoppedException;
use App\ContentFeed\HtmlToMarkdownConverter;
use App\Entity\ContentDocument;
use App\Entity\ContentSyncState;
use App\Enum\ContentFetchState;
use App\Model\ContentFeed\ContentManifest;
use App\Model\ContentFeed\ManifestItem;
use App\Model\ContentFeed\PageFetchResult;
use App\Repository\ContentDocumentRepository;
use App\Repository\ContentSyncStateRepository;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
class ContentSyncerTest extends TestCase
{
/** @var array<string, ContentDocument> what the fake database holds */
private array $stored = [];
private ContentSyncState $state;
private ContentFeedClient&Stub $client;
protected function setUp(): void
{
$this->state = new ContentSyncState();
$this->client = $this->createStub(ContentFeedClient::class);
}
public function testAFailedManifestChangesNothing(): void
{
$this->stored = ['page:1:de' => $this->document('page', 1)];
$this->client->method('manifest')->willThrowException(new ContentFeedStoppedException('Manifest has no items'));
$report = $this->syncer()->run();
self::assertSame('Manifest has no items', $report->stoppedBy);
self::assertArrayHasKey('page:1:de', $this->stored);
self::assertNull($this->state->getManifestGeneratedAt());
self::assertSame('Manifest has no items', $this->state->getLastError());
}
public function testItemsMissingFromTheManifestAreDeleted(): void
{
$this->stored = ['page:1:de' => $this->document('page', 1), 'page:2:de' => $this->document('page', 2)];
$this->client->method('manifest')->willReturn($this->manifest([$this->pageItem(1)]));
$this->client->method('page')->willReturn(PageFetchResult::ok('<p>Eins</p>'));
$report = $this->syncer()->run();
self::assertSame(1, $report->deleted);
self::assertSame(['page:1:de'], array_keys($this->stored));
self::assertEquals(new \DateTimeImmutable('2026-10-08 08:15:00'), $this->state->getManifestGeneratedAt());
}
public function testRecordsAbsentFromTheResponseAreDeleted(): void
{
$this->client->method('manifest')->willReturn($this->manifest([$this->recordItem('product', 412), $this->recordItem('product', 413)]));
$this->client->method('records')->willReturn([412 => ['uid' => 412, 'name' => 'Val Thorens', 'code' => 'VTH', 'text' => ['teaser' => '<p>Ski</p>']]]);
$report = $this->syncer()->run();
self::assertSame(1, $report->records);
self::assertSame(['product:412:de'], array_keys($this->stored));
self::assertSame("# Val Thorens\n\nSki", $this->stored['product:412:de']->getBodyMd());
self::assertTrue($this->stored['product:412:de']->isIndexed());
}
public function testADetailPageIsMergedIntoItsRecordAndNotIndexedItself(): void
{
$this->client->method('manifest')->willReturn($this->manifest([
$this->recordItem('product', 412, url: null),
$this->pageItem(7, detailOf: [['type' => 'product', 'uid' => 412]]),
]));
$this->client->method('records')->willReturn([412 => ['uid' => 412, 'name' => 'Val Thorens', 'text' => []]]);
$this->client->method('page')->willReturn(PageFetchResult::ok('<article><h2>Anreise</h2><p>Mit dem Bus</p></article>'));
$this->syncer()->run();
$record = $this->stored['product:412:de'];
$page = $this->stored['page:7:de'];
self::assertSame("# Val Thorens\n\n## Anreise\n\nMit dem Bus", $record->getBodyMd());
self::assertSame('product:412', $page->getMergedInto());
self::assertFalse($page->isIndexed());
self::assertSame('https://www.ep-reisen.de/page-7', $record->getUrl(), 'a record without a page of its own is linkable through its detail page');
self::assertSame('ep-reisen', $record->getSite());
}
public function testFailedAndEmptyPagesAreRememberedUntilTheyChange(): void
{
$this->client->method('manifest')->willReturn($this->manifest([$this->pageItem(1), $this->pageItem(2)]));
$this->client->method('page')->willReturnOnConsecutiveCalls(PageFetchResult::failed(404), PageFetchResult::ok('<article><img src="a.jpg"></article>'));
$this->syncer()->run();
self::assertSame(ContentFetchState::Failed, $this->stored['page:1:de']->getFetchState());
self::assertSame(ContentFetchState::Empty, $this->stored['page:2:de']->getFetchState());
self::assertFalse($this->stored['page:1:de']->isStale());
self::assertFalse($this->stored['page:2:de']->isStale());
}
public function testATimedOutPageIsRetriedNextRun(): void
{
$this->client->method('manifest')->willReturn($this->manifest([$this->pageItem(1)]));
$this->client->method('page')->willReturn(PageFetchResult::transient());
$report = $this->syncer()->run();
self::assertSame(1, $report->failed);
self::assertTrue($this->stored['page:1:de']->isStale());
}
public function testOutsideTheNightWindowAtMostFiftyPagesAreFetched(): void
{
$items = array_map(fn (int $uid): ManifestItem => $this->pageItem($uid), range(1, 60));
$this->client->method('manifest')->willReturn($this->manifest($items));
$this->client->method('page')->willReturn(PageFetchResult::ok('<p>Text</p>'));
$report = $this->syncer('2026-10-08 12:00:00')->run();
self::assertSame(50, $report->pages);
self::assertSame(10, $report->deferred);
}
public function testInsideTheNightWindowEveryDuePageIsFetched(): void
{
$items = array_map(fn (int $uid): ManifestItem => $this->pageItem($uid), range(1, 60));
$this->client->method('manifest')->willReturn($this->manifest($items));
$this->client->method('page')->willReturn(PageFetchResult::ok('<p>Text</p>'));
// 02:00 in Berlin.
$report = $this->syncer('2026-10-08 00:00:00')->run();
self::assertSame(60, $report->pages);
self::assertSame(0, $report->deferred);
}
public function testAPausedFeedIsNotCalled(): void
{
$this->state->setPausedUntil(new \DateTimeImmutable('2026-10-08 13:00:00', new \DateTimeZone('UTC')));
$client = $this->createMock(ContentFeedClient::class);
$client->expects(self::never())->method('manifest');
$this->client = $client;
$report = $this->syncer()->run();
self::assertNotNull($report->stoppedBy);
}
private function syncer(string $now = '2026-10-08 12:00:00'): ContentSyncer
{
$repository = $this->createStub(ContentDocumentRepository::class);
$repository->method('findAllByKey')->willReturnCallback(fn (): array => $this->stored);
$stateRepository = $this->createStub(ContentSyncStateRepository::class);
$stateRepository->method('get')->willReturn($this->state);
$entityManager = $this->createStub(EntityManagerInterface::class);
$entityManager->method('persist')->willReturnCallback(function (object $entity): void {
if ($entity instanceof ContentDocument) {
$this->stored[$entity->getKey()] = $entity;
}
});
$entityManager->method('remove')->willReturnCallback(function (object $entity): void {
if ($entity instanceof ContentDocument) {
unset($this->stored[$entity->getKey()]);
}
});
$entityManager->method('contains')->willReturnCallback(fn (object $entity): bool => $entity instanceof ContentDocument && ($this->stored[$entity->getKey()] ?? null) === $entity);
$converter = new HtmlToMarkdownConverter();
return new ContentSyncer(
$this->client,
$repository,
$stateRepository,
new DocumentBodyBuilder($converter),
$converter,
$this->createStub(TripCatalog::class),
$entityManager,
new MockClock(new \DateTimeImmutable($now, new \DateTimeZone('UTC'))),
new NullLogger(),
);
}
/**
* @param list<ManifestItem> $items
*/
private function manifest(array $items): ContentManifest
{
$keyed = [];
foreach ($items as $item) {
$keyed[$item->key()] = $item;
}
return new ContentManifest(new \DateTimeImmutable('2026-10-08 08:15:00'), $keyed, ['www.ep-reisen.de']);
}
/**
* @param list<array{type: string, uid: int}> $detailOf
*/
private function pageItem(int $uid, array $detailOf = []): ManifestItem
{
return new ManifestItem('page', $uid, 'de', 'ep-reisen', 'https://www.ep-reisen.de/page-'.$uid, new \DateTimeImmutable('2026-09-30 12:00:00'), 'https://www.ep-reisen.de/page-'.$uid.'?type=1730', 'Page '.$uid, null, ['Skireisen'], $detailOf);
}
private function recordItem(string $type, int $uid, ?string $url = 'https://www.ep-reisen.de/record'): ManifestItem
{
return new ManifestItem($type, $uid, 'de', null === $url ? null : 'ep-reisen', $url, new \DateTimeImmutable('2026-09-30 12:00:00'));
}
private function document(string $type, int $uid): ContentDocument
{
return new ContentDocument($type, $uid, 'de', new \DateTimeImmutable('2026-09-01 12:00:00'));
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Tests\ContentFeed;
use App\ContentFeed\DocumentBodyBuilder;
use App\ContentFeed\HtmlToMarkdownConverter;
use PHPUnit\Framework\TestCase;
class DocumentBodyBuilderTest extends TestCase
{
private DocumentBodyBuilder $builder;
protected function setUp(): void
{
$this->builder = new DocumentBodyBuilder(new HtmlToMarkdownConverter());
}
public function testProductSectionsInFixedOrderWithFaqs(): void
{
$body = $this->builder->build('product', [
'url' => 'https://www.ep-reisen.de/val-thorens/',
'name' => 'Val Thorens Apartments',
'subline' => 'Apartments',
'headline' => 'Skiurlaub in Val Thorens',
'text' => [
'skiPassDescription' => '<p>6 Tage 3 Vallées</p>',
'teaser' => '<p>Ski in, ski out</p>',
'includedServices' => '<ul><li>Unterkunft</li></ul>',
'feature' => null,
],
'faqs' => [
['question' => 'Ist der Skipass inklusive?', 'answer' => '<p>Ja.</p>', 'category' => null],
['question' => 'Ohne Antwort', 'answer' => null],
],
]);
self::assertSame(
"# Val Thorens Apartments\n\nApartments\n\nSkiurlaub in Val Thorens\n\nSki in, ski out\n\n## Leistungen\n\n- Unterkunft\n\n## Skipass\n\n6 Tage 3 Vallées\n\n## Häufige Fragen\n\n### Ist der Skipass inklusive?\n\nJa.",
$body,
);
}
public function testHotelFactsAreSearchableText(): void
{
$body = $this->builder->build('hotel', [
'name' => 'Chalets de Rosael',
'facts' => ['bedsCount' => 120, 'altitude' => null, 'sauna' => true, 'wifi' => false],
'text' => ['description' => '<p>Ruhige Lage</p>'],
]);
self::assertSame("# Chalets de Rosael\n\n## Beschreibung\n\nRuhige Lage\n\n## Fakten\n\n- Betten: 120\n- Sauna: ja\n- WLAN: nein", $body);
}
public function testNewsUsesTitleDateBodyAndTags(): void
{
$record = [
'title' => 'Packlisten',
'teaser' => 'Was muss mit?',
'bodytext' => '<p>Helm &amp; Brille</p>',
'datetime' => '2026-07-23T14:00:00+02:00',
'tags' => ['Urlaubsvorbereitung', ['title' => 'Anfänger:in']],
];
self::assertSame('Packlisten', $this->builder->title('news', $record));
self::assertSame("# Packlisten\n\nVeröffentlicht am 23.07.2026\n\nWas muss mit?\n\nHelm & Brille\n\nThemen: Urlaubsvorbereitung, Anfänger:in", $this->builder->build('news', $record));
}
public function testRecordWithoutTextIsJustItsTitle(): void
{
self::assertSame('# Frankreich', $this->builder->build('country', ['name' => 'Frankreich', 'text' => []]));
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Tests\ContentFeed;
use App\ContentFeed\HtmlToMarkdownConverter;
use PHPUnit\Framework\TestCase;
class HtmlToMarkdownConverterTest extends TestCase
{
private HtmlToMarkdownConverter $converter;
protected function setUp(): void
{
$this->converter = new HtmlToMarkdownConverter();
}
public function testDropsEmbedsMediaAndFormsWithTheirContent(): void
{
$markdown = $this->converter->convert('<p>Text</p><script>alert(1)</script><style>p{}</style><noscript>No JS</noscript><iframe src="https://youtube.com"></iframe><img src="a.jpg" alt="Bild"><picture><source></picture><svg><text>Icon</text></svg><form><label>Name</label></form>');
self::assertSame('Text', $markdown);
}
public function testKeepsHeadingsListsTablesAndEmphasis(): void
{
$markdown = $this->converter->convert('<h2>Leistungen</h2><ul><li><strong>Skipass</strong></li><li><em>Bus</em></li></ul><table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>');
self::assertSame("## Leistungen\n\n- **Skipass**\n- *Bus*\n\n| A | B |\n|---|---|\n| 1 | 2 |", $markdown);
}
public function testResolvesRelativeLinksAgainstTheDocumentUrl(): void
{
$markdown = $this->converter->convert('<a href="/agb">AGB</a> <a href="kontakt">Kontakt</a> <a href="mailto:[email protected]">Mail</a> <a href="https://snowzone.net/">SZ</a>', 'https://www.ep-reisen.de/info/reise');
self::assertSame('[AGB](https://www.ep-reisen.de/agb) [Kontakt](https://www.ep-reisen.de/info/kontakt) [Mail](mailto:[email protected]) [SZ](https://snowzone.net/)', $markdown);
}
public function testCollapsesTemplateWhitespaceAndDecodesEntities(): void
{
$markdown = $this->converter->convert("<div>\n <a href=\"/x\">\n Ski-\n Gruppenhäuser\n </a></div>\n\n\n\n<p>Essen&nbsp;&amp;&nbsp;Trinken</p>", 'https://www.ep-reisen.de/');
self::assertSame("[Ski- Gruppenhäuser](https://www.ep-reisen.de/x)\n\nEssen & Trinken", $markdown);
}
public function testEmptyInputIsEmpty(): void
{
self::assertSame('', $this->converter->convert(null));
self::assertSame('', $this->converter->convert(' '));
self::assertSame('', $this->converter->convert('<article><img src="a.jpg"></article>'));
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\McpAccessTokenHandler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
class McpAccessTokenHandlerTest extends TestCase
{
public function testTheConfiguredTokenAuthenticatesTheN8nUser(): void
{
self::assertSame(McpAccessTokenHandler::USER, (new McpAccessTokenHandler('s3cret'))->getUserBadgeFrom('s3cret')->getUserIdentifier());
}
public function testAWrongTokenIsRejected(): void
{
$this->expectException(BadCredentialsException::class);
(new McpAccessTokenHandler('s3cret'))->getUserBadgeFrom('guess');
}
public function testAnUnconfiguredTokenRejectsEverything(): void
{
$this->expectException(BadCredentialsException::class);
(new McpAccessTokenHandler(''))->getUserBadgeFrom('');
}
}