Files
myep/tests/Service/ProductExportBuilderTest.php
T

310 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\ProductExportParser;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\Export;
use App\Service\ProductExportBuilder;
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 ProductExportBuilderTest extends TestCase
{
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
{
$export = $this->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('<name>Hin Leiterin</name>', '', (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'));
$xml = preg_replace('#<leistung id="3" idbuspro="193802".*?</leistung>#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,
'returnLeg' => false,
'returnPrice' => null,
'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 testJoinsEachPickupWithItsReturnLegVerbatim(): void
{
$pickups = $this->rowFor('900-222931')['pickups'];
self::assertSame([false, true, true, false], array_column($pickups, 'returnLeg'));
self::assertSame(3.0, $pickups[1]['returnPrice']);
self::assertNull($pickups[2]['returnPrice'], 'listed on the return leg without a price');
}
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'
<zustieg id="1" idbuspro="4" zeit="00:30" />
<zustieg id="2" idbuspro="27" zeit="02:45" />
<zustieg id="3" idbuspro="3" zeit="27.03.2027 22:00:00" />
<zustieg id="4" idbuspro="89" zeit="01:30" />
<zustieg id="5" idbuspro="86" zeit="27.03.2027 23:30:00" />
<zustieg id="6" idbuspro="74" zeit="03:45" />
<zustieg id="7" idbuspro="262" zeit="04:45" />
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'
<zustieg id="1" idbuspro="4" zeit="30.10.2027 23:30:00" />
<zustieg id="2" idbuspro="27" zeit="02:15" />
<zustieg id="3" idbuspro="55" zeit="02:59" />
<zustieg id="4" idbuspro="0" zeit="31.10.2027 02:30:00" />
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<string, mixed>
*/
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<array<string, mixed>>
*/
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('#<zustiege>.*?</zustiege>#s', '<zustiege>'.$zustiege.'</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,
new TravelParser(new AgeConstraintParserRegistry([])),
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;
}
}