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', 'hotelCode' => 'HOT2', '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 testANameInTheQueryReturnsWhatItNamesAndNotWhatMentionsIt(): void { $rows = [ $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]), $this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]), $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520)]), ]; // Every page mentions every word: the texts must not be asked when a name answers. $catalog = $this->catalog($rows, static fn (): array => [['type' => 'product', 'uid' => 1]]); $search = static fn (string $query): array => array_map( static fn (array $result): string => $result['dateCode'].'/'.$result['hotelCode'], $catalog->search(new TripCriteria(query: $query))['results'], ); self::assertSame(['VTH2/HOT2'], $search('Hotel Zwei'), 'a hotel stands for itself, not for its trip'); self::assertSame(['VTH2/HOT2'], $search('zwei')); self::assertSame(['VTH2/HOT2'], $search('Gibt es das Hotel Zwei in Frankreich?'), 'spelled out among other words, the hotel beats the country'); self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('Hotel')); self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('val thorens')); self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('Urlaub in Frankreich')); self::assertSame([], $search('Gruppenreise Uni X'), 'a hidden trip is not offered, and nothing else in its place'); } public function testACodeInTheQueryFindsItsTripHotelOrDate(): void { $catalog = $this->catalog([ $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]), $this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]), $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520)]), ]); $search = static fn (string $query): array => array_map( static fn (array $result): string => $result['dateCode'].'/'.$result['hotelCode'], $catalog->search(new TripCriteria(query: $query))['results'], ); self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('vth')); self::assertSame(['VTH2/HOT1', 'VTH3/HOT1'], $search('HOT1ALIAS')); self::assertSame(['VTH3/HOT1'], $search('vth-3')); } public function testHotelsAreAlsoFoundByTheirBusProNameAndShownByItWithoutAWebsiteRecord(): void { $catalog = $this->catalog([ $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]), $this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]), $this->row('VTH', 2, '2026-12-05', 'HOT3', [$this->room(520)]), ]); $search = static fn (string $query): array => array_map( static fn (array $result): string => $result['hotelCode'].'/'.$result['hotel'], $catalog->search(new TripCriteria(query: $query))['results'], ); self::assertSame(['HOT3/Oceanpeak'], $search('oceanpeak'), 'no website record: found and shown by BusPro\'s name'); self::assertSame(['HOT3/Oceanpeak'], $search('hot3')); self::assertSame(['HOT3/Oceanpeak'], $search('Hotel Oceanpeak in Frankreich')); self::assertSame(['HOT1/Hotel Eins'], $search('Residence Eins'), 'BusPro\'s wording finds the hotel, the website\'s names it'); self::assertSame('Oceanpeak', $catalog->get('VTH', 'HOT3')['dates'][0]['hotels'][0]['hotel'] ?? null); self::assertSame([], $catalog->get('VTH', 'HOT3')['hotels'] ?? null, 'not the trip\'s other hotels'); } public function testOtherWordsAreLookedUpInThePageTextsEveryTermBeforeAnyTerm(): void { $rows = [ $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]), $this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]), ]; $asked = []; $hits = static function (string $booleanQuery) use (&$asked): array { $asked[] = $booleanQuery; return match ($booleanQuery) { '+sauna* +whirlpool*' => [['type' => 'hotel', 'uid' => 11]], 'kamin* whirlpool*' => [['type' => 'country', 'uid' => 2]], default => [], }; }; $search = fn (string $query): array => array_column($this->catalog($rows, $hits)->search(new TripCriteria(query: $query))['results'], 'hotelCode'); self::assertSame(['HOT2'], $search('Sauna Whirlpool')); self::assertSame(['+sauna* +whirlpool*'], $asked); self::assertSame(['HOT2', 'HOT1'], $search('Kamin Whirlpool'), 'no page has both, so any of them'); self::assertSame([], $search('Kamin')); self::assertSame(['HOT2', 'HOT1'], $search('was ist das'), 'nothing searchable restricts nothing'); } 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> $rows * @param (\Closure(string): list)|null $textHits the full-text index, by boolean query */ private function catalog(array $rows, ?\Closure $textHits = null): 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')->willReturnCallback($textHits ?? static fn (): array => []); $hotels = []; foreach (['HOT1' => "Agence X\nRésidence Eins Wochenreisen", 'HOT3' => 'Oceanpeak'] as $code => $name) { $hotel = new Hotel(); $hotel->id = crc32($code); $hotel->code = $code; $hotel->name = $name; $hotels[$hotel->id] = $hotel; } $hotelLoader = $this->createStub(HotelLoader::class); $hotelLoader->method('loadAll')->willReturn($hotels); return new TripCatalog($export, $repository, $hotelLoader, new MockClock('2026-10-08 10:00:00'), new NullLogger()); } /** * @param array $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> $rooms * * @return array */ 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 */ 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']; } }