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->createStub(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->createStub(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&Stub { $connection = $this->createStub(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, [], ); } }