Files
myep/tests/Chatbot/TripCatalogTest.php
T

172 lines
8.6 KiB
PHP

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