276 lines
15 KiB
PHP
276 lines
15 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\Enum\HotelType;
|
|
use App\Enum\RoomType;
|
|
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 testSearchReturnsOneResultPerTravelDateAndHotelLikeTheWebsite(): void
|
|
{
|
|
$result = $this->catalog([
|
|
$this->row('VTH', 1, '2026-09-01', 'HOT1', [$this->room(100)]),
|
|
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520, status: 'Anfrage')]),
|
|
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500), $this->room(450, available: 0), $this->room(99, status: 'Buchungsstop')], withExtras: true),
|
|
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]),
|
|
])->search(new TripCriteria());
|
|
|
|
self::assertSame(3, $result['total']);
|
|
self::assertSame(['VTH2', 'VTH2', 'VTH3'], array_column($result['results'], 'dateCode'));
|
|
self::assertSame([
|
|
'product' => 'Val Thorens Apartments',
|
|
'code' => 'VTH',
|
|
'dateCode' => 'VTH2',
|
|
'hotel' => 'Hotel Zwei',
|
|
'accommodation' => null,
|
|
'destination' => ['country' => 'Frankreich'],
|
|
'concept' => 'Skiurlaub ohne Programm',
|
|
'dateFrom' => '2026-12-05',
|
|
'dateTo' => '2026-12-12',
|
|
'nights' => 7,
|
|
'priceFrom' => 480.0,
|
|
'includes' => [],
|
|
'roomTypes' => ['DZ'],
|
|
'availability' => 'frei',
|
|
'flags' => [],
|
|
'url' => 'https://www.ep-reisen.de/val-thorens/',
|
|
'site' => 'ep-reisen',
|
|
], $result['results'][0], 'sorted by date, then the cheaper hotel first');
|
|
|
|
$hotelOne = $result['results'][1];
|
|
self::assertSame('Hotel Eins', $hotelOne['hotel']);
|
|
self::assertSame('Sportclub', $hotelOne['accommodation']);
|
|
self::assertSame(500.0, $hotelOne['priceFrom'], 'the cheapest room with places left; the stopped room does not count');
|
|
self::assertSame(['Skipass', 'Bus', 'Halbpension', 'Programm'], $hotelOne['includes']);
|
|
self::assertSame(['neu'], $hotelOne['flags']);
|
|
self::assertSame('auf Anfrage', $result['results'][2]['availability']);
|
|
self::assertSame('2026-10-08T06:00:00+00:00', $result['dataAsOf']);
|
|
self::assertSame(1, count($this->catalog([$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(1)]), $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(1)])])->search(new TripCriteria(limit: 1))['results']));
|
|
}
|
|
|
|
public function testPersonsNeedsAGroupARoomTakesAndEnoughPlaces(): 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')]),
|
|
// Only takes groups of 4 or more.
|
|
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(300, available: 3, maxPax: 6, minPax: 4)]),
|
|
];
|
|
|
|
self::assertSame(['VTH2', 'VTH3'], array_column($this->catalog($rows)->search(new TripCriteria(persons: 5))['results'], 'dateCode'));
|
|
self::assertSame(['VTH3'], array_column($this->catalog($rows)->search(new TripCriteria(persons: 6))['results'], 'dateCode'));
|
|
self::assertSame(['VTH2'], array_column($this->catalog($rows)->search(new TripCriteria(persons: 2))['results'], 'dateCode'));
|
|
}
|
|
|
|
public function testTheTravelPeriodContainsTheTripAndPricesApplyToTheAbPrice(): 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-05'), dateTo: new \DateTimeImmutable('2026-12-12')))['results']);
|
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-01'), dateTo: new \DateTimeImmutable('2026-12-10')))['results'], 'overlap is not enough');
|
|
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(priceMin: 400, priceMax: 450))['results']);
|
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(priceMax: 449.99))['results']);
|
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(priceMin: 451))['results']);
|
|
}
|
|
|
|
public function testAccommodationBoardRoomTypeNightsAndBusFilters(): void
|
|
{
|
|
$rows = [
|
|
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(400, label: 'Bett im Mehrbettzimmer', maxPax: 1), $this->room(500, code: 'Baby', maxPax: 1)], withExtras: true),
|
|
$this->row('VTH', 3, '2027-01-09', 'HOT2', [$this->room(300, maxPax: 4)], days: 3),
|
|
];
|
|
$search = fn (TripCriteria $criteria): array => array_column($this->catalog($rows)->search($criteria)['results'], 'dateCode');
|
|
|
|
self::assertSame(['VTH2'], $search(new TripCriteria(accommodation: HotelType::SportsClub)));
|
|
self::assertSame(['VTH2'], $search(new TripCriteria(board: 'halbpension')));
|
|
self::assertSame([], $search(new TripCriteria(board: 'Vollpension')));
|
|
self::assertSame(['VTH2'], $search(new TripCriteria(roomType: RoomType::WithOthers)));
|
|
self::assertSame([], $search(new TripCriteria(roomType: RoomType::ForOne)), 'a shared bed is no Einzelzimmer, and the baby row is no room');
|
|
self::assertSame(['VTH3'], $search(new TripCriteria(roomType: RoomType::ForFour)));
|
|
self::assertSame(['VTH3'], $search(new TripCriteria(nightsMax: 4)));
|
|
self::assertSame(['VTH2'], $search(new TripCriteria(nightsMin: 5)));
|
|
self::assertSame(['VTH2'], $search(new TripCriteria(bus: true)));
|
|
self::assertSame(['VTH3'], $search(new TripCriteria(bus: false)));
|
|
}
|
|
|
|
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', 'surchargeOutbound' => 5.9, 'surchargeReturn' => 5.9, 'surchargeRoundTrip' => 11.8]], $trip['dates'][0]['pickups']);
|
|
self::assertArrayNotHasKey('guide', $trip['dates'][0]);
|
|
}
|
|
|
|
public function testGetWithoutEditorialKeepsTheStructuredFieldsAndDropsThePageTexts(): void
|
|
{
|
|
$catalog = $this->catalog([$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)])]);
|
|
$full = $catalog->get('VTH');
|
|
$plain = $catalog->get('VTH', editorial: false);
|
|
|
|
self::assertNotNull($full);
|
|
self::assertNotNull($plain);
|
|
self::assertArrayHasKey('description', $full);
|
|
self::assertArrayHasKey('description', $full['hotels'][0]);
|
|
self::assertArrayNotHasKey('description', $plain);
|
|
self::assertArrayNotHasKey('description', $plain['hotels'][0]);
|
|
|
|
unset($full['description'], $full['hotels'][0]['description']);
|
|
self::assertSame($full, $plain);
|
|
}
|
|
|
|
public function testPickupSurchargesArePerLegAndAnUnpricedLegIsFree(): void
|
|
{
|
|
$row = $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]);
|
|
$row['pickups'] = [
|
|
['busProId' => 18, 'code' => 'ESS', 'city' => 'Essen', 'street' => 'Hbf', 'price' => 17.9, 'returnLeg' => true, 'returnPrice' => null, 'datetime' => null],
|
|
['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => null, 'returnLeg' => false, 'returnPrice' => null, 'datetime' => null],
|
|
];
|
|
|
|
$pickups = $this->catalog([$row])->get('VTH')['dates'][0]['pickups'] ?? null;
|
|
|
|
self::assertSame([
|
|
['city' => 'Essen', 'place' => 'Hbf', 'surchargeOutbound' => 17.9, 'surchargeReturn' => 0.0, 'surchargeRoundTrip' => 17.9],
|
|
['city' => 'Frankfurt', 'place' => 'Flughafen', 'surchargeOutbound' => 0.0],
|
|
], $pickups, 'BusPro bills only priced legs; no return leg, no return surcharge');
|
|
}
|
|
|
|
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 testGetTakesADateCodeAndNarrowsToThatDate(): void
|
|
{
|
|
$catalog = $this->catalog([
|
|
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]),
|
|
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520)]),
|
|
]);
|
|
|
|
$trip = $catalog->get('vth-3');
|
|
|
|
self::assertSame('VTH', $trip['code'] ?? null);
|
|
self::assertSame(['VTH3'], array_column($trip['dates'], 'dateCode'));
|
|
self::assertNull($catalog->get('VTH9'));
|
|
}
|
|
|
|
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'], 'type' => 2, 'new' => true, 'earlybird' => false, '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, bool $withExtras = false, int $days = 7): array
|
|
{
|
|
$dateFrom = new \DateTimeImmutable($from);
|
|
|
|
return [
|
|
'product' => ['code' => $code, 'busProId' => 1],
|
|
'date' => ['code' => $code.$dateId, 'busProId' => $dateId, 'from' => $from, 'to' => $dateFrom->modify(sprintf('+%d days', $days))->format('Y-m-d'), 'type' => 'F'],
|
|
'hotel' => ['code' => $hotelCode, 'busProId' => crc32($hotelCode)],
|
|
'selectionGroups' => $withExtras ? [
|
|
['busProId' => 7, 'label' => 'Leistungen Skipass', 'selections' => [['busProId' => 92, 'label' => '6 Tage Skipass']]],
|
|
['busProId' => 71, 'label' => 'Leistungen Verpflegung', 'selections' => [['busProId' => 3, 'label' => 'Halbpension']]],
|
|
['busProId' => 86, 'label' => 'Leistungen Programm', 'selections' => [['busProId' => 5, 'label' => 'E&P-Programm']]],
|
|
] : [],
|
|
'transportServices' => $withExtras ? [
|
|
['busProId' => 1, 'subType' => 'BUS', 'label' => 'Bus', 'price' => 0, 'status' => 'Frei', 'direction' => 'HIN'],
|
|
['busProId' => 2, 'subType' => 'BUS', 'label' => 'Bus', 'price' => 0, 'status' => 'Frei', 'direction' => 'RUECK'],
|
|
] : [
|
|
['busProId' => 3, 'subType' => 'BUS', 'label' => 'Bus', 'price' => 0, 'status' => 'Buchungsstop', 'direction' => 'HIN'],
|
|
],
|
|
'rooms' => $rooms,
|
|
'pickups' => [
|
|
['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => 5.9, 'returnLeg' => true, 'returnPrice' => 5.9, 'datetime' => $dateFrom->modify('-1 day')->format('Y-m-d').'T23:30:00'],
|
|
['busProId' => 0, 'code' => null, 'city' => null, 'street' => null, 'price' => null, 'returnLeg' => false, 'returnPrice' => 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', int $minPax = 1, string $code = 'DZ', string $label = 'Zimmer'): array
|
|
{
|
|
return ['code' => $code, 'label' => $label, 'minPax' => $minPax, 'maxPax' => $maxPax, 'nights' => 7, 'price' => $price, 'available' => $available, 'status' => $status, 'boardCode' => 'HP'];
|
|
}
|
|
}
|