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('

Eins

')); $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' => '

Ski

']]]); $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('

Anreise

Mit dem Bus

')); $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('
')); $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('

Text

')); $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('

Text

')); // 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 $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 $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')); } }