feat: import destinations from the MyE&P travel export
This commit is contained in:
1 parent
5c7e702eab
commit
0f76c5a7b6
15 files changed
+791
-422
No files matched your search
@@ -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,
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user