Files
myep/tests/ContentFeed/ContentSyncerTest.php
T

228 lines
9.5 KiB
PHP

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