244 lines
8.9 KiB
PHP
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;
|
|
}
|
|
}
|