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([ ['dateCode' => 'VTH2', 'dateFrom' => '2026-12-05', 'dateTo' => '2026-12-12', 'priceFrom' => 450.0, 'availability' => 'frei'], ['dateCode' => 'VTH3', '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 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 */ 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 $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): 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 */ 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']; } }