298 lines
11 KiB
PHP
298 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Service;
|
|
|
|
use App\BusProNet\Model\Hotel;
|
|
use App\BusProNet\Model\Pickup;
|
|
use App\BusProNet\XmlLoader\HotelLoader;
|
|
use App\BusProNet\XmlLoader\PickupLoader;
|
|
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
|
|
use App\BusProNet\XmlParser\TravelParser;
|
|
use App\Model\Export;
|
|
use App\Service\TravelExportBuilder;
|
|
use League\Flysystem\DirectoryListing;
|
|
use League\Flysystem\FileAttributes;
|
|
use League\Flysystem\FilesystemOperator;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
|
|
|
class TravelExportBuilderTest extends TestCase
|
|
{
|
|
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
|
|
{
|
|
$rows = $this->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('<?xml version="1.0"?><reisen><reise code="DWWMP "><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027" code="DWWMW010127"><hotel idbuspro="1"/></termin></reise></reisen>')->rows;
|
|
|
|
self::assertSame('DWWMP', $rows[0]['productCode'], 'taken from <reise>, trimmed');
|
|
self::assertSame('DWWMW010127', $rows[0]['code']);
|
|
}
|
|
|
|
public function testTheProductCodeIsNullWhenTheTravelCarriesNone(): void
|
|
{
|
|
$rows = $this->build('<?xml version="1.0"?><reisen><reise code=""><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027" code="SCE010127"><hotel idbuspro="1"/></termin></reise></reisen>')->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 version="1.0"?>
|
|
<reisen><reise><termin idbuspro="1" termin="28.03.2027" bis="03.04.2027"><hotel idbuspro="1"/><zustiege>
|
|
<zustieg idbuspro="10" zeit="27.03.2027 23:30:00"/>
|
|
<zustieg idbuspro="11" zeit="02:45"/>
|
|
<zustieg idbuspro="12" zeit="03:45"/>
|
|
</zustiege></termin></reise></reisen>
|
|
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 version="1.0"?>
|
|
<reisen><reise><termin idbuspro="1" termin="30.10.2027" bis="06.11.2027"><hotel idbuspro="1"/><zustiege>
|
|
<zustieg idbuspro="10" zeit="30.10.2027 23:30:00"/>
|
|
<zustieg idbuspro="11" zeit="02:15"/>
|
|
<zustieg idbuspro="12" zeit="02:59"/>
|
|
</zustiege></termin></reise></reisen>
|
|
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('<?xml version="1.0"?><reisen><reise><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027"><hotel idbuspro="1"/></termin></reise></reisen>')->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<string, mixed>
|
|
*/
|
|
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<string> $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([]), false),
|
|
$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;
|
|
}
|
|
}
|