build()->rows;
$keys = array_map(
static fn (array $row): string => $row['busProId'].'-'.$row['hotel']['busProId'],
$rows,
);
self::assertSame(['900-1', '900-2', '901-1'], $keys);
self::assertSame($keys, array_unique($keys), 'the pair the consumer upserts on must be unique export-wide');
}
public function testCarriesTravelAndHotelMasterData(): void
{
$row = $this->rowFor('900-1');
self::assertSame(900, $row['busProId']);
self::assertIsInt($row['busProId']);
self::assertSame('SBW261226', $row['code']);
self::assertSame('SBW', $row['productCode']);
self::assertSame('Semesterende - Portes du Soleil', $row['product'], 'the product label is trimmed');
self::assertSame('2026-12-26', $row['dateFrom']);
self::assertSame('2026-12-30', $row['dateTo']);
self::assertSame([
'busProId' => 1,
'code' => 'H1',
'name' => 'Hotel Eins',
'costUnit' => '84 85',
'country' => 'CH',
'countryCode' => 'ch',
], $row['hotel']);
}
public function testCarriesTheProductCodeOfTheEnclosingTravelRatherThanTheDateCode(): void
{
$rows = $this->build('')->rows;
self::assertSame('DWWMP', $rows[0]['productCode'], 'taken from , trimmed');
self::assertSame('DWWMW010127', $rows[0]['code']);
}
public function testTheProductCodeIsNullWhenTheTravelCarriesNone(): void
{
$rows = $this->build('')->rows;
self::assertNull($rows[0]['productCode']);
}
public function testResolvesBothZeitShapesAndRollsAnOvernightPickupOntoTheNextDay(): void
{
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertSame('23:59', $pickups[10]['time']);
self::assertSame('2026-12-26T23:59:00', $pickups[10]['datetime']);
// Time only, so it defaults to the departure date -- which would put it before the
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
self::assertSame('02:15', $pickups[11]['time']);
self::assertSame('2026-12-27T02:15:00', $pickups[11]['datetime']);
}
public function testEmitsNoOffsetInSummerEither(): void
{
$pickups = $this->rowFor('901-1')['pickups'];
self::assertSame('2027-07-15T08:00:00', $pickups[0]['datetime']);
}
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
{
// Berlin skips 02:00-02:59 on 28.03.2027; BusPro still plans a stop at 02:45.
$pickups = $this->build(<<<'XML'
XML)->rows[0]['pickups'];
self::assertSame(['23:30', '02:45', '03:45'], array_column($pickups, 'time'));
self::assertSame(['2027-03-27T23:30:00', '2027-03-28T02:45:00', '2027-03-28T03: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->build(<<<'XML'
XML)->rows[0]['pickups'];
self::assertSame(['2027-10-30T23:30:00', '2027-10-31T02:15:00', '2027-10-31T02:59:00'], array_column($pickups, 'datetime'));
}
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
{
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
self::assertSame('2027-07-15T09:30:00', $pickups[11]['datetime'], 'no roll when the order already holds');
}
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
{
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertArrayHasKey(12, $pickups);
self::assertNull($pickups[12]['time']);
self::assertNull($pickups[12]['datetime']);
self::assertSame('Z12', $pickups[12]['code'], 'master data still applies to an entry with no time');
}
public function testKeepsAPickupMissingFromMasterDataAndCountsIt(): void
{
$export = $this->build();
$pickups = array_column($export->rows[0]['pickups'], null, 'busProId');
// Dropping it would hide a real departure point from the disposition UI.
self::assertArrayHasKey(99, $pickups);
self::assertNull($pickups[99]['code']);
self::assertNull($pickups[99]['city']);
self::assertNull($pickups[99]['street']);
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
self::assertSame('2026-12-26T21:00:00', $pickups[99]['datetime']);
// Counted once, not once per row that references it.
self::assertSame(1, $export->pickupsWithoutMasterData);
}
public function testPickupsKeepSourceOrder(): void
{
$order = array_column($this->rowFor('900-1')['pickups'], 'busProId');
self::assertSame([10, 11, 12, 99], $order, 'source order, never sorted by time');
}
public function testPickupsAreAnEmptyArrayRatherThanNullWhenThereAreNone(): void
{
$rows = $this->build('')->rows;
self::assertSame([], $rows[0]['pickups']);
}
public function testDropsAndCountsRowsWhoseHotelIsMissingFromMasterData(): void
{
$export = $this->build();
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 902 === $r['busProId']));
// One hotel on travel 902, two on the travel with the unparseable date.
self::assertSame(3, $export->skipped);
}
public function testDropsEveryRowOfATravelWhoseDateCannotBeParsed(): void
{
$export = $this->build();
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 903 === $r['busProId']));
}
public function testCollapsesADuplicatePairLastWins(): void
{
$export = $this->build(null, ['Ziel_1.xml', 'Ziel_2.xml']);
$matching = array_values(array_filter(
$export->rows,
static fn (array $r): bool => 900 === $r['busProId'] && 1 === $r['hotel']['busProId'],
));
self::assertCount(1, $matching);
self::assertSame('Zweiter Treffer', $matching[0]['product']);
}
/**
* @return array
*/
public function testCarriesTheSourceExportTimestamp(): void
{
$export = $this->build(null, ['Ziel_1.xml'], "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'));
}
public function testTheSourceExportTimestampIsNullWithoutAMarker(): void
{
self::assertNull($this->build()->sourceGeneratedAt);
}
private function rowFor(string $key): array
{
foreach ($this->build()->rows as $row) {
if ($row['busProId'].'-'.$row['hotel']['busProId'] === $key) {
return $row;
}
}
self::fail('No row for '.$key);
}
/**
* @param list $files
*/
private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): Export
{
$contents = [
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
'Ziel_2.xml' => (string) file_get_contents(__DIR__.'/../Resources/travel_export_data_duplicate.xml'),
];
$filesystem = $this->createStub(FilesystemOperator::class);
$filesystem->method('listContents')->willReturn(new DirectoryListing(array_map(
static fn (string $path): FileAttributes => new FileAttributes($path),
$files,
)));
$filesystem->method('read')->willReturnCallback(
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? ''
);
$filesystem->method('fileExists')->willReturnCallback(
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
);
$hotelLoader = $this->createStub(HotelLoader::class);
$hotelLoader->method('loadAll')->willReturn([
1 => $this->hotel(1, 'H1', 'Hotel Eins', '84 85', 'CH', 'ch'),
2 => $this->hotel(2, 'H2', 'Hotel Zwei', null, 'D', 'de'),
]);
$pickupLoader = $this->createStub(PickupLoader::class);
$pickupLoader->method('loadAll')->willReturn([
10 => $this->pickup(10),
11 => $this->pickup(11),
12 => $this->pickup(12),
]);
return (new TravelExportBuilder(
$hotelLoader,
$pickupLoader,
new TravelParser(new AgeConstraintParserRegistry([])),
$filesystem,
new TagAwareAdapter(new ArrayAdapter()),
new NullLogger(),
))->build();
}
private function hotel(int $id, string $code, string $name, ?string $costUnit, string $country, string $countryCode): Hotel
{
$hotel = new Hotel();
$hotel->id = $id;
$hotel->code = $code;
$hotel->name = $name;
$hotel->costUnit = $costUnit;
$hotel->country = $country;
$hotel->countryCode = $countryCode;
return $hotel;
}
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;
}
}