build(); $keys = array_map( static fn (array $row): string => $row['date']['busProId'].'-'.$row['hotel']['busProId'], $export->rows, ); self::assertSame(['900-222931', '900-222932'], $keys); } public function testCarriesIdentityFromTheTravelXmlAlone(): void { $row = $this->rowFor('900-222931'); self::assertSame(['code' => 'DKIWE', 'busProId' => 1127], $row['product']); self::assertSame([ 'code' => 'DKIWE041226', 'busProId' => 900, 'from' => '2026-12-04', 'to' => '2026-12-06', 'type' => 'F', ], $row['date']); self::assertSame(['code' => 'DKIWEKT', 'busProId' => 222931], $row['hotel']); } public function testDropsAndCountsRowsOfAnUnparseableDate(): void { self::assertSame(1, $this->build()->skipped); } public function testEmitsEveryRoomButThePseudoRoomVerbatim(): void { $rooms = $this->rowFor('900-222931')['rooms']; self::assertSame(['MBZET', '3erET'], array_column($rooms, 'code'), 'PDGS is not a room; the code is trimmed'); self::assertSame([ 'busProId' => 74, 'code' => 'MBZET', 'label' => 'Bett im Mehrbettzimmer', 'category' => 'GR', 'minPax' => 1, 'maxPax' => 1, 'nights' => 2, 'price' => 229.5, 'available' => 24, 'status' => 'Frei', 'boardCode' => 'Lt. Auss.', 'boardBusProId' => 2, ], $rooms[0]); // Buchungsstop rooms reach the consumer: "ausgebucht" is its call. self::assertSame('Buchungsstop', $rooms[1]['status']); self::assertSame(3, $rooms[1]['maxPax'], 'MaxPax is read despite its casing'); } public function testAMissingRoomPriceOrAvailabilityIsNullNotZero(): void { $room = $this->rowFor('900-222932')['rooms'][0]; self::assertNull($room['price']); self::assertNull($room['available']); } public function testThePseudoRoomBecomesThePseudoPriceOfItsHotelOnly(): void { self::assertSame(299.0, $this->rowFor('900-222931')['pseudoPrice']); self::assertNull($this->rowFor('900-222932')['pseudoPrice']); } public function testKeepsTheThreeServiceListsApartAndUnfiltered(): void { $first = $this->rowFor('900-222931'); $second = $this->rowFor('900-222932'); self::assertSame([193804, 193811, 193802, 193820], array_column($first['transportServices'], 'busProId'), 'RUECK and Buchungsstop are kept'); self::assertSame([193805, 193830], array_column($first['dateServices'], 'busProId'), 'EIN is kept; hotel services do not leak in'); self::assertSame([193840], array_column($first['hotelServices'], 'busProId')); self::assertSame([], $second['hotelServices'], 'a hotel without its own list gets an empty one, not the date-level one'); self::assertSame($first['dateServices'], $second['dateServices']); } public function testServicesAreNumericAndNullWhereAbsent(): void { $row = $this->rowFor('900-222931'); self::assertSame([ 'busProId' => 193804, 'subType' => 'PKW', 'label' => 'Eigene Anreise mit Rabatt', 'price' => -50.0, 'status' => 'Frei', 'direction' => 'HIN', ], $row['transportServices'][0]); self::assertSame(1234.5, $row['transportServices'][3]['price'], 'thousands separator'); self::assertNull($row['dateServices'][0]['direction'], 'general services have no direction'); self::assertNull($row['dateServices'][1]['price'], 'a missing price is null, not 0'); } public function testEmitsEverySelectionGroupInSourceOrder(): void { $groups = $this->rowFor('900-222931')['selectionGroups']; self::assertSame([1, 7, 99], array_column($groups, 'busProId')); self::assertSame('Leistungen Skipass', $groups[1]['label']); self::assertSame([ ['busProId' => 92, 'label' => '3 Tage Skipass'], ['busProId' => 93, 'label' => 'Skipass Region'], ], $groups[1]['selections']); self::assertSame([], $groups[2]['selections']); } public function testTakesTheGuideFromTheOutboundBus(): void { self::assertSame(['name' => 'Hin Leiterin', 'phone' => '0171 123'], $this->rowFor('900-222931')['guide']); } public function testTheGuideIsNullWithoutAnOutboundOne(): void { $xml = str_replace('Hin Leiterin', '', (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml')); $xml = preg_replace('##s', '', $xml); self::assertNull($this->build((string) $xml)->rows[0]['guide']); } public function testEmitsPickupsVerbatimWithResolvedDatetimes(): void { $pickups = $this->rowFor('900-222931')['pickups']; self::assertSame([4, 27, 55, 0], array_column($pickups, 'busProId'), 'source order, id 0 kept'); self::assertSame([ 'busProId' => 4, 'code' => 'Z4', 'city' => 'Stadt 4', 'street' => 'Strasse 4', 'price' => 5.9, 'datetime' => '2026-12-03T23:30:00', ], $pickups[0]); self::assertNull($pickups[1]['price'], 'an absent price is null'); self::assertSame('2026-12-04T01:45:00', $pickups[1]['datetime']); self::assertSame(0.0, $pickups[2]['price'], 'a stated 0,00 is kept as 0'); self::assertNull($pickups[2]['code']); } public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void { // DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night. $pickups = $this->pickupsFor('28.03.2027', <<<'XML' XML); self::assertSame([ '2027-03-28T00:30:00', '2027-03-28T02:45:00', '2027-03-27T22:00:00', '2027-03-28T01:30:00', '2027-03-27T23:30:00', '2027-03-28T03:45:00', '2027-03-28T04:45:00', ], array_column($pickups, 'datetime')); } public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void { // Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th. $pickups = $this->pickupsFor('30.10.2027', <<<'XML' XML); self::assertSame([ '2027-10-30T23:30:00', '2027-10-31T02:15:00', '2027-10-31T02:59:00', '2027-10-31T02:30:00', ], array_column($pickups, 'datetime')); } public function testDoesNotLevelBoardingPointPrices(): void { // Point 27 is priced on the return leg only. The booking path levels that onto the // outbound entry; the website publishes the outbound price as exported. self::assertNull($this->rowFor('900-222931')['pickups'][1]['price']); } public function testCountsPickupsWithoutMasterDataButNotTheIdlessOne(): void { self::assertSame(1, $this->build()->pickupsWithoutMasterData); } public function testCarriesTheSourceExportTimestamp(): void { $export = $this->build(null, "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n"); self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c')); } /** * @return array */ private function rowFor(string $key): array { foreach ($this->build()->rows as $row) { if ($row['date']['busProId'].'-'.$row['hotel']['busProId'] === $key) { return $row; } } self::fail('No row for '.$key); } /** * The fixture's first date moved to $dateFrom, with $zustiege as its boarding list. * * @return list> */ private function pickupsFor(string $dateFrom, string $zustiege): array { $xml = (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'); $xml = str_replace('termin="04.12.2026"', 'termin="'.$dateFrom.'"', $xml); $xml = (string) preg_replace('#.*?#s', ''.$zustiege.'', $xml); return $this->build($xml)->rows[0]['pickups']; } private function build(?string $xml = null, ?string $marker = null): Export { $xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'); $filesystem = $this->createStub(FilesystemOperator::class); $filesystem->method('listContents')->willReturn(new DirectoryListing([new FileAttributes('Ziel_1127.xml')])); $filesystem->method('read')->willReturnCallback( static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $xml ); $filesystem->method('fileExists')->willReturnCallback( static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker ); $pickupLoader = $this->createStub(PickupLoader::class); $pickupLoader->method('loadAll')->willReturn([ 4 => $this->pickup(4), 27 => $this->pickup(27), ]); return (new ProductExportBuilder( $pickupLoader, // Levelling on, to prove the export does not go through it. new TravelParser(new AgeConstraintParserRegistry([]), true), new ProductExportParser(), $filesystem, new TagAwareAdapter(new ArrayAdapter()), new NullLogger(), ))->build(); } private function pickup(int $id): Pickup { $pickup = new Pickup(); $pickup->id = $id; $pickup->code = 'Z'.$id; $pickup->city = 'Stadt '.$id; $pickup->street = 'Strasse '.$id; return $pickup; } }