Files
myep/tests/Service/ProductExportBuilderTest.php
T

244 lines
8.9 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,
'datetime' => '2026-12-03T23:30:00+01:00',
], $pickups[0]);
self::assertNull($pickups[1]['price'], 'an absent price is null');
self::assertSame('2026-12-04T01:45:00+01: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 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);
}
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;
}
}