feat: import destinations from the MyE&P travel export

This commit is contained in:
fromme committed 2026-09-29 12:57:54 +02:00
1 parent 5c7e702eab
commit 0f76c5a7b6
15 files changed
+791 -422

No files matched your search

-147
View File
@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\DataProvider\PickupDataProvider;
use App\BusProNet\Model\Hotel;
use App\Command\BpnImportCommand;
use Doctrine\DBAL\Connection;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The import writes a destination's cost unit and country from the hotel base data, on
* update as well as on insert, so it can only run once MyE&P reports both fields. Until it
* does, a run would blank them on the 2,206 destinations that carry a cost unit and the
* 2,259 that carry a country - silently, because nothing reads them back until an invoice
* is stamped. These tests pin the refusal, and that it happens before any write.
*/
class BpnImportCommandTest extends TestCase
{
public function testItRefusesToRunWhenNoHotelReportsACostUnit(): void
{
$tester = $this->runImport([$this->hotel(costUnit: null, countryCode: 'ch')]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Kostenstellen', $tester->getDisplay());
$this->assertStringNotContainsString('Länderkennungen', $tester->getDisplay());
}
public function testItRefusesToRunWhenNoHotelReportsACountryCode(): void
{
$tester = $this->runImport([$this->hotel(costUnit: '4711', countryCode: null)]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Länderkennungen', $tester->getDisplay());
}
public function testItNamesBothFieldsWhenNeitherIsReported(): void
{
$tester = $this->runImport([$this->hotel(costUnit: null, countryCode: null)]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
}
public function testAnEmptyStringDoesNotCountAsAReportedField(): void
{
$tester = $this->runImport([$this->hotel(costUnit: '', countryCode: '')]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
}
public function testOneHotelCarryingAFieldIsEnoughToPassTheGuard(): void
{
// most hotels legitimately have no kostenstelle upstream, so the guard asks whether
// the field is reported at all, not whether every hotel fills it
$tester = $this->runImport([
$this->hotel(costUnit: null, countryCode: null),
$this->hotel(costUnit: '4711', countryCode: 'ch'),
]);
$display = $tester->getDisplay();
$this->assertStringNotContainsString('Kostenstellen', $display);
$this->assertStringNotContainsString('Länderkennungen', $display);
}
public function testItRefusesToRunWithoutAnyHotelBaseData(): void
{
$tester = $this->runImport([]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Hotel-Stammdaten', $tester->getDisplay());
}
/**
* @param list<Hotel> $hotels
*/
private function runImport(array $hotels): CommandTester
{
$keyed = [];
foreach ($hotels as $index => $hotel) {
$keyed[$index + 1] = $hotel;
}
$tester = new CommandTester(new BpnImportCommand(
$this->refusingConnection(),
$this->provider(HotelDataProvider::class, $keyed),
$this->provider(PickupDataProvider::class, []),
new NullLogger(),
// an auto stubbed listing, so it yields no files. The guards under test all run
// before it, and the one case that gets past them stops there without writing.
$this->createMock(FilesystemOperator::class),
));
$tester->execute([]);
return $tester;
}
/**
* @template T of HotelDataProvider|PickupDataProvider
*
* @param class-string<T> $class
*
* @return T&MockObject
*/
private function provider(string $class, array $all): object
{
$provider = $this->createMock($class);
$provider->method('getAll')->willReturn($all);
return $provider;
}
/**
* Any query at all means the guard let a write through, which is the whole failure this
* covers, so the connection fails the test rather than the command.
*/
private function refusingConnection(): Connection&MockObject
{
$connection = $this->createMock(Connection::class);
$connection->expects($this->never())->method($this->anything());
return $connection;
}
private function hotel(?string $costUnit, ?string $countryCode): Hotel
{
return (new Hotel())
->setBusProId(163959)
->setCode('LPJGR')
->setName('Sportclub Jenatsch')
->setCostUnit($costUnit)
->setCountryCode($countryCode)
;
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\MyEpImportCommand;
use App\MyEp\Api\ApiException;
use App\MyEp\Import\DestinationRow;
use App\MyEp\Import\TravelExport;
use App\MyEp\Import\TravelExportClient;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The import writes a destination's cost unit and country from the export, on update as well as
* on insert, so it can only run once MyE&P reports both fields. Until it does, a run would blank
* them on the 2,206 destinations that carry a cost unit and the 2,259 that carry a country -
* silently, because nothing reads them back until an invoice is stamped. Most of these tests pin
* the refusal, and that it happens before any write.
*/
class MyEpImportCommandTest extends TestCase
{
public function testItRefusesToRunWhenNoRowReportsACostUnit(): void
{
$tester = $this->runImport([$this->row(costUnit: null, country: 'ch')]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Kostenstellen', $tester->getDisplay());
$this->assertStringNotContainsString('Länderkennungen', $tester->getDisplay());
}
public function testItRefusesToRunWhenNoRowReportsACountryCode(): void
{
$tester = $this->runImport([$this->row(costUnit: '4711', country: null)]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Länderkennungen', $tester->getDisplay());
}
public function testItNamesBothFieldsWhenNeitherIsReported(): void
{
$tester = $this->runImport([$this->row(costUnit: null, country: null)]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
}
public function testAnEmptyStringDoesNotCountAsAReportedField(): void
{
$tester = $this->runImport([$this->row(costUnit: '', country: '')]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
}
/**
* Most hotels legitimately have no kostenstelle upstream, so the guard asks whether the field
* is reported at all, not whether every row fills it.
*/
public function testOneRowCarryingAFieldIsEnoughToPassTheGuard(): void
{
$tester = $this->runImport(
[$this->row(costUnit: null, country: null), $this->row(costUnit: '4711', country: 'ch')],
$this->writingConnection(existingId: 7),
);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$this->assertStringNotContainsString('Kostenstellen', $tester->getDisplay());
}
/**
* A run that touches nothing and reports success reads as "nothing changed upstream" rather
* than as a failure to read the export at all.
*/
public function testItRefusesToRunOnAnEmptyExport(): void
{
$tester = $this->runImport([]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('keine Reisen', $tester->getDisplay());
}
public function testItRefusesToRunWhenTheExportCannotBeRead(): void
{
$client = $this->createMock(TravelExportClient::class);
$client->method('fetchAll')->willThrowException(new ApiException('gateway timeout'));
$tester = new CommandTester(new MyEpImportCommand(
$this->refusingConnection(),
$client,
new NullLogger(),
));
$tester->execute([]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('konnte nicht von MyE&P geladen werden', $tester->getDisplay());
}
public function testAKnownPairIsUpdatedRatherThanInserted(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchOne')->willReturn(42);
$connection->expects($this->once())->method('update')->with(
'destination',
$this->callback(function (array $payload): bool {
// the hotel name and code are the point: leaving them out of the update was what
// let a renamed hotel keep a stale name forever and drop out of house matching
$this->assertArrayHasKey('hotel', $payload);
$this->assertArrayHasKey('hotel_code', $payload);
$this->assertArrayNotHasKey('bus_pro_id', $payload);
return true;
}),
['id' => 42],
);
$connection->expects($this->never())->method('insert');
$tester = $this->runImport([$this->row(costUnit: '4711', country: 'ch')], $connection);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$this->assertStringContainsString('0 Destinationen angelegt, 1 aktualisiert', $tester->getDisplay());
}
public function testAnUnknownPairIsInserted(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchOne')->willReturn(false);
$connection->expects($this->once())->method('insert')->with(
'destination',
$this->callback(function (array $payload): bool {
$this->assertSame(12778, $payload['bus_pro_id']);
$this->assertSame(222928, $payload['hotel_bus_pro_id']);
$this->assertSame('ch', $payload['country']);
return true;
}),
);
$connection->expects($this->never())->method('update');
$tester = $this->runImport([$this->row(costUnit: '4711', country: 'ch')], $connection);
$this->assertStringContainsString('1 Destinationen angelegt, 0 aktualisiert', $tester->getDisplay());
}
/**
* Both counters are losses MyE&P made on its side and would otherwise not surface anywhere.
*/
public function testTheOperatorIsToldWhatMyEpDropped(): void
{
$tester = $this->runImport(
[$this->row(costUnit: '4711', country: 'ch')],
$this->writingConnection(existingId: 7),
skipped: 3,
pickupsWithoutMasterData: 21,
);
$display = $tester->getDisplay();
$this->assertStringContainsString('3 Reise-Hotel-Kombinationen verworfen', $display);
$this->assertStringContainsString('21 Zustiege ohne Stammdaten', $display);
}
public function testACleanExportSaysNothingAboutDroppedRows(): void
{
$tester = $this->runImport(
[$this->row(costUnit: '4711', country: 'ch')],
$this->writingConnection(existingId: 7),
);
$this->assertStringNotContainsString('verworfen', $tester->getDisplay());
}
/**
* @param DestinationRow[] $rows
*/
private function runImport(
array $rows,
?Connection $connection = null,
int $skipped = 0,
int $pickupsWithoutMasterData = 0,
): CommandTester {
$client = $this->createMock(TravelExportClient::class);
$client->method('fetchAll')->willReturn(
new TravelExport($rows, $skipped, $pickupsWithoutMasterData, '2026-09-29T03:20:00+02:00')
);
$tester = new CommandTester(new MyEpImportCommand(
$connection ?? $this->refusingConnection(),
$client,
new NullLogger(),
));
$tester->execute([]);
return $tester;
}
/**
* Any query at all means the guard let a write through, which is the whole failure the
* refusal tests cover, so the connection fails the test rather than the command.
*/
private function refusingConnection(): Connection&MockObject
{
$connection = $this->createMock(Connection::class);
$connection->expects($this->never())->method($this->anything());
return $connection;
}
private function writingConnection(int $existingId): Connection&MockObject
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchOne')->willReturn($existingId);
return $connection;
}
private function row(?string $costUnit, ?string $country): DestinationRow
{
return new DestinationRow(
12778,
'DWWSCH070227',
'Davos - Sportclub Weissfluh',
new \DateTimeImmutable('2027-02-07'),
new \DateTimeImmutable('2027-02-12'),
222928,
'DWWGRU2',
'Sportclub Weißfluh',
$costUnit,
$country,
[],
);
}
}
+24 -1
View File
@@ -60,9 +60,32 @@ class DestinationRowTest extends TestCase
'city' => 'Karlsruhe',
'street' => 'A5 - Autohof Bruchsal',
'time' => '23:59',
'datetime' => '2026-04-04T23:59:00+02:00',
]], $row->pickups);
}
/**
* MyE&P sends `time` as a bare HH:MM, so this is the only field that says which calendar day
* a boarding point belongs to. Dropping it left App\Service\Assignment\PickupDepartureOrder
* with no anchor and a naive clock sort, which puts a 00:30 stop before the 22:00 departure.
*/
public function testTheResolvedDepartureTimestampIsCarried(): void
{
$row = DestinationRow::fromArray($this->getRowData());
$this->assertSame('2026-04-04T23:59:00+02:00', $row->pickups[0]['datetime']);
}
public function testAPickupWithoutAResolvedTimestampIsStillCarried(): void
{
$row = DestinationRow::fromArray($this->getRowData(['pickups' => [
['busProId' => 27, 'city' => 'Ulm', 'street' => 'Busparkplatz', 'time' => null],
]]));
$this->assertNull($row->pickups[0]['datetime']);
$this->assertCount(1, $row->pickups);
}
public function testStoredPickupsCanBeReadBackAsModels(): void
{
$row = DestinationRow::fromArray($this->getRowData());
@@ -129,7 +152,7 @@ class DestinationRowTest extends TestCase
'hotel' => 'Sportclub Jenatsch',
'hotel_code' => 'LPJGR',
'hotel_bus_pro_id' => 163959,
'pickups' => '[{"busProId":4,"city":"Karlsruhe","street":"A5 - Autohof Bruchsal","time":"23:59"}]',
'pickups' => '[{"busProId":4,"city":"Karlsruhe","street":"A5 - Autohof Bruchsal","time":"23:59","datetime":"2026-04-04T23:59:00+02:00"}]',
'cost_unit' => '4711',
'country' => 'ch',
'created_at' => '2026-08-13 03:00:00',
+65 -5
View File
@@ -21,7 +21,7 @@ class TravelExportClientTest extends TestCase
$this->page([$this->row(3, 300)], 2, 2),
]);
$rows = $this->getClientInstance($httpClient)->fetchAll();
$rows = $this->getClientInstance($httpClient)->fetchAll()->rows;
$this->assertCount(3, $rows);
$this->assertEquals([1, 2, 3], array_map(fn ($row) => $row->busProId, $rows));
@@ -62,7 +62,7 @@ class TravelExportClientTest extends TestCase
], 1, 1),
]);
$rows = $this->getClientInstance($httpClient)->fetchAll();
$rows = $this->getClientInstance($httpClient)->fetchAll()->rows;
$this->assertCount(1, $rows);
$this->assertEquals(2, $rows[0]->busProId);
@@ -72,7 +72,7 @@ class TravelExportClientTest extends TestCase
{
$httpClient = $this->mockClient([$this->page([], 1, 1)]);
$this->assertEquals([], $this->getClientInstance($httpClient)->fetchAll());
$this->assertEquals([], $this->getClientInstance($httpClient)->fetchAll()->rows);
}
public function testAPayloadWithoutADataListFails(): void
@@ -97,14 +97,71 @@ class TravelExportClientTest extends TestCase
'code' => 'AAA',
'name' => 'Haus',
'costUnit' => null,
'country' => 'ch',
'country' => 'CH',
'countryCode' => 'ch',
],
'pickups' => [],
];
}
private function page(array $rows, int $page, int $totalPages): MockResponse
/**
* Both counters are losses MyE&P made on its side - a dropped travel, a boarding point with no
* town - and the import is the only place they surface, so the client has to carry them out.
*/
public function testTheCountersMyEpReportsAreCarriedOut(): void
{
$httpClient = $this->mockClient([$this->page([$this->row(1, 100)], 1, 1, skipped: 3, pickupsWithoutMasterData: 21)]);
$export = $this->getClientInstance($httpClient)->fetchAll();
$this->assertSame(3, $export->skipped);
$this->assertSame(21, $export->pickupsWithoutMasterData);
$this->assertSame('2026-09-29T03:20:00+02:00', $export->generatedAt);
}
/**
* They describe the whole export rather than the page, so they repeat unchanged and the first
* page answers for the run. Reading the last one instead would be just as correct today and
* wrong the moment a page is served from a newer build.
*/
public function testTheCountersAreReadFromTheFirstPage(): void
{
$httpClient = $this->mockClient([
$this->page([$this->row(1, 100)], 1, 2, skipped: 3, pickupsWithoutMasterData: 21),
$this->page([$this->row(2, 200)], 2, 2, skipped: 99, pickupsWithoutMasterData: 99),
]);
$export = $this->getClientInstance($httpClient)->fetchAll();
$this->assertSame(3, $export->skipped);
$this->assertSame(21, $export->pickupsWithoutMasterData);
}
/**
* An export whose meta block says nothing must not read as one that dropped nothing by
* accident of a missing key, but zero is the only honest default available here.
*/
public function testAMetaBlockWithoutCountersReadsAsZero(): void
{
$httpClient = new MockHttpClient(
[new MockResponse(json_encode(['data' => [$this->row(1, 100)], 'meta' => ['totalPages' => 1]]))],
'https://myep.example/',
);
$export = $this->getClientInstance($httpClient)->fetchAll();
$this->assertSame(0, $export->skipped);
$this->assertSame(0, $export->pickupsWithoutMasterData);
$this->assertNull($export->generatedAt);
}
private function page(
array $rows,
int $page,
int $totalPages,
int $skipped = 0,
int $pickupsWithoutMasterData = 0,
): MockResponse {
return new MockResponse(json_encode([
'data' => $rows,
'meta' => [
@@ -112,6 +169,9 @@ class TravelExportClientTest extends TestCase
'perPage' => 500,
'total' => count($rows),
'totalPages' => $totalPages,
'skipped' => $skipped,
'pickupsWithoutMasterData' => $pickupsWithoutMasterData,
'generatedAt' => '2026-09-29T03:20:00+02:00',
],
]));
}