228 lines
9.5 KiB
PHP
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'));
|
|
}
|
|
}
|