feat: unfiltered product export for the website's catalogue import

This commit is contained in:
fromme committed 2026-09-30 12:19:10 +02:00
1 parent 272c811f4f
commit 1d42565a84
11 files changed
+970 -33

No files matched your search

+62 -17
View File
@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Model\TravelExport;
use App\Model\TravelExportQuery;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
@@ -16,7 +17,7 @@ class ExportControllerTest extends TestCase
{
public function testPaginatesAndReportsWholeExportFigures(): void
{
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
$meta = $this->call(new ExportQuery(1, 5))['meta'];
self::assertSame(1, $meta['page']);
self::assertSame(5, $meta['perPage']);
@@ -27,8 +28,8 @@ class ExportControllerTest extends TestCase
public function testCountersAreIdenticalOnEveryPage(): void
{
// Whole-export figures repeated per page, so a consumer can abort after page 1.
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
$first = $this->call(new ExportQuery(1, 5))['meta'];
$last = $this->call(new ExportQuery(3, 5))['meta'];
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) {
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
@@ -37,8 +38,8 @@ class ExportControllerTest extends TestCase
public function testConsecutivePagesDoNotOverlap(): void
{
$first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId');
$first = array_column($this->call(new ExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new ExportQuery(2, 5))['data'], 'busProId');
self::assertSame([1, 2, 3, 4, 5], $first);
self::assertSame([6, 7, 8, 9, 10], $second);
@@ -47,7 +48,7 @@ class ExportControllerTest extends TestCase
public function testTheLastPageIsShort(): void
{
$data = $this->call(new TravelExportQuery(3, 5))['data'];
$data = $this->call(new ExportQuery(3, 5))['data'];
self::assertCount(2, $data);
self::assertSame([11, 12], array_column($data, 'busProId'));
@@ -55,7 +56,7 @@ class ExportControllerTest extends TestCase
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
{
$body = $this->call(new TravelExportQuery(99, 5));
$body = $this->call(new ExportQuery(99, 5));
self::assertSame([], $body['data']);
self::assertSame(12, $body['meta']['total']);
@@ -84,9 +85,9 @@ class ExportControllerTest extends TestCase
public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void
{
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new TravelExport([], 0, 0, new \DateTimeImmutable()));
$builder->method('build')->willReturn(new Export([], 0, 0, new \DateTimeImmutable()));
$meta = json_decode((string) $this->controller($builder)->travels(new TravelExportQuery())->getContent(), true)['meta'];
$meta = json_decode((string) $this->controller($builder)->travels(new ExportQuery())->getContent(), true)['meta'];
self::assertArrayHasKey('sourceGeneratedAt', $meta);
self::assertNull($meta['sourceGeneratedAt']);
@@ -103,13 +104,57 @@ class ExportControllerTest extends TestCase
$this->expectException(\RuntimeException::class);
$this->controller($builder)->travels(new TravelExportQuery());
$this->controller($builder)->travels(new ExportQuery());
}
public function testProductsUseTheSameEnvelopeAndPaging(): void
{
$rows = [];
for ($i = 1; $i <= 3; ++$i) {
$rows[] = ['date' => ['busProId' => $i], 'hotel' => ['busProId' => 1]];
}
$productBuilder = $this->createStub(ProductExportBuilder::class);
$productBuilder->method('build')->willReturn(new Export(
$rows,
2,
5,
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
));
$response = $this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)
->products(new ExportQuery(2, 2));
$body = json_decode((string) $response->getContent(), true);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame([3], array_column(array_column($body['data'], 'date'), 'busProId'));
self::assertSame([
'page' => 2,
'perPage' => 2,
'total' => 3,
'totalPages' => 2,
'skipped' => 2,
'pickupsWithoutMasterData' => 5,
'sourceGeneratedAt' => '2026-09-28T23:10:15+02:00',
'generatedAt' => '2026-09-29T03:20:00+02:00',
], $body['meta']);
}
public function testAProductBuilderFailureIsNotDowngradedToATwoHundred(): void
{
$productBuilder = $this->createStub(ProductExportBuilder::class);
$productBuilder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
$this->expectException(\RuntimeException::class);
$this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)->products(new ExportQuery());
}
/**
* @return array<string, mixed>
*/
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
private function call(?ExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
{
$rows = [];
for ($i = 1; $i <= 12; ++$i) {
@@ -117,7 +162,7 @@ class ExportControllerTest extends TestCase
}
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new TravelExport(
$builder->method('build')->willReturn(new Export(
$rows,
$skipped,
$withoutMasterData,
@@ -125,16 +170,16 @@ class ExportControllerTest extends TestCase
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
));
$response = $this->controller($builder)->travels($query ?? new TravelExportQuery());
$response = $this->controller($builder)->travels($query ?? new ExportQuery());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
private function controller(TravelExportBuilder $builder): ExportController
private function controller(TravelExportBuilder $builder, ?ProductExportBuilder $productBuilder = null): ExportController
{
$controller = new ExportController($builder);
$controller = new ExportController($builder, $productBuilder ?? $this->createStub(ProductExportBuilder::class));
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse -- which is what this endpoint produces in production anyway.
+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise id="1" idbuspro="1127" code="DKIWE">
<!-- Two hotels on one date: one row per pair, date-level data shared, hotel-level data not. -->
<termin id="1" idbuspro="900" idprodukt="1127" termin="04.12.2026" bis="06.12.2026" code="DKIWE041226" reiseart="F">
<selektiongruppe id="1" idbuspro="1" bezeichnung="Reisezeitraum">
<selektion id="1" idbuspro="863" bezeichnung="Wintersportreise" />
</selektiongruppe>
<selektiongruppe id="2" idbuspro="7" bezeichnung="Leistungen Skipass">
<selektion id="1" idbuspro="92" bezeichnung="3 Tage Skipass" />
<selektion id="2" idbuspro="93" bezeichnung="Skipass Region" />
</selektiongruppe>
<selektiongruppe id="3" idbuspro="99" bezeichnung="Leistungen Personal" />
<text>Davos Klosters</text>
<lei_befoerderung>
<leistung id="1" idbuspro="193804" unterart="PKW">
<text>Eigene Anreise mit Rabatt</text>
<preis>-50,00</preis>
<status>Frei</status>
<richtung>HIN</richtung>
</leistung>
<!-- A return-leg bus with a guide: the guide must not be taken from it. -->
<leistung id="2" idbuspro="193811" unterart="BUS">
<text>Bus Rückfahrt</text>
<preis>0,00</preis>
<status>Buchungsstop</status>
<richtung>RUECK</richtung>
<zustiegsplanung>
<reiseleiter><name>Rück Leiter</name><telefon>000</telefon></reiseleiter>
</zustiegsplanung>
</leistung>
<leistung id="3" idbuspro="193802" unterart="BUS">
<text>Bus Hinfahrt</text>
<preis>0,00</preis>
<status>Frei</status>
<richtung>HIN</richtung>
<zustiegsplanung>
<reiseleiter><name>Hin Leiterin</name><telefon>0171 123</telefon></reiseleiter>
</zustiegsplanung>
</leistung>
<leistung id="4" idbuspro="193820" unterart="BAH">
<text>Zug</text>
<preis>1.234,50</preis>
<status>Anfrage</status>
<richtung>HIN</richtung>
</leistung>
</lei_befoerderung>
<lei_sonstiges>
<leistung id="1" idbuspro="193805" unterart="SON">
<text>Keycard-Pfand (Pflichtabgabe)</text>
<preis>5,40</preis>
<status>Frei</status>
</leistung>
<leistung id="2" idbuspro="193830" unterart="EIN">
<text>Einzelzimmerzuschlag</text>
<status>Frei</status>
</leistung>
</lei_sonstiges>
<hotel id="1" code="DKIWEKT" idbuspro="222931">
<!-- Same per-list id as the date-level Keycard entry, different service. -->
<lei_sonstiges>
<leistung id="1" idbuspro="193840" unterart="SPA">
<text>Skipass Hotel</text>
<preis>60,00</preis>
<status>Frei</status>
</leistung>
</lei_sonstiges>
<zimmer>
<preis zimmercode="MBZET " idbuspro_zimmer="74" zimmertext="Bett im Mehrbettzimmer" MinPax="1" MaxPax="1" naechte="2" kat="GR" vpcode="Lt. Auss." idbuspro_vp="2" preis="229,50" status="Frei" verfuegbar="24" />
<preis zimmercode="PDGS" idbuspro_zimmer="999" zimmertext="Pseudo" MinPax="1" MaxPax="1" naechte="2" preis="299,00" status="Frei" verfuegbar="0" />
<preis zimmercode="3erET" idbuspro_zimmer="98" zimmertext="3er Zimmer" MinPax="3" MaxPax="3" naechte="2" kat="GR" vpcode="HP" idbuspro_vp="3" preis="259,00" status="Buchungsstop" verfuegbar="0" />
</zimmer>
</hotel>
<hotel id="2" code="DKIWEK2" idbuspro="222932">
<zimmer>
<preis zimmercode="DZ" idbuspro_zimmer="11" zimmertext="Doppelzimmer" MinPax="2" MaxPax="2" naechte="2" kat="DZ" vpcode="HP" idbuspro_vp="3" preis="" status="Anfrage" verfuegbar="" />
</zimmer>
</hotel>
<zustiege>
<zustieg id="1" idbuspro="4" zeit="03.12.2026 23:30:00" preis="5,90" />
<!-- Bare time after the 23:30 above: rolls onto the next calendar day. -->
<zustieg id="2" idbuspro="27" zeit="01:45" />
<!-- Referenced but absent from zustiege.xml. -->
<zustieg id="3" idbuspro="55" zeit="03.12.2026 21:00:00" preis="0,00" />
<!-- No id: the consumer drops it, not the export. -->
<zustieg id="4" idbuspro="0" zeit="03.12.2026 20:00:00" />
</zustiege>
<zustiege_rueck>
<!-- Priced on the return leg only: levelling would copy 3,00 onto the outbound entry. -->
<zustieg_rueck id="1" idbuspro="27" preis="3,00" />
</zustiege_rueck>
</termin>
<!-- Unparseable date: its hotel row is dropped and counted. -->
<termin id="2" idbuspro="901" termin="kaputt" bis="06.12.2026" code="DKIWE000000">
<hotel id="1" code="DKIWEKT" idbuspro="222931" />
</termin>
</reise>
</reisen>
+243
View File
@@ -0,0 +1,243 @@
<?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;
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use App\Model\Export;
use App\Service\TravelExportBuilder;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
@@ -182,7 +182,7 @@ class TravelExportBuilderTest extends TestCase
/**
* @param list<string> $files
*/
private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): TravelExport
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'),