From d5dfb6c5502e3069faac3436dcd7f42bf2407f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 22 Apr 2026 11:20:16 +0200 Subject: [PATCH] fix: collect dataset transfer failures instead of breaking execution --- src/Command/BpnXmlSyncCommand.php | 43 ++++++++++--- tests/Command/BpnXmlSyncCommandTest.php | 81 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/Command/BpnXmlSyncCommand.php b/src/Command/BpnXmlSyncCommand.php index 7a9ec54..a190d6a 100644 --- a/src/Command/BpnXmlSyncCommand.php +++ b/src/Command/BpnXmlSyncCommand.php @@ -66,7 +66,7 @@ class BpnXmlSyncCommand extends Command $totalSyncedCount = 0; $totalDeletedCount = 0; $hasSyncedFiles = false; - $hasFailures = false; + $failedDatasets = []; $travelSynced = false; $travelRemoteTimestamp = null; $snapshotResult = null; @@ -76,7 +76,7 @@ class BpnXmlSyncCommand extends Command $targetResult = $this->syncTarget($target, $force, $dryRun, $io, $output); if ($targetResult['failed']) { - $hasFailures = true; + $failedDatasets[] = $targetResult['dataset']; } if (false === $targetResult['synced']) { @@ -109,10 +109,6 @@ class BpnXmlSyncCommand extends Command } } - if ($hasFailures) { - return Command::FAILURE; - } - if ($dryRun) { return Command::SUCCESS; } @@ -136,14 +132,38 @@ class BpnXmlSyncCommand extends Command $successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted); } - $io->success($successMessage); - $this->logger->info('XML sync completed', [ + if ([] !== $failedDatasets) { + $successMessage .= sprintf(', failed datasets: %s', implode(', ', $failedDatasets)); + } + + $logContext = [ 'files_updated' => $totalSyncedCount, 'files_deleted' => $totalDeletedCount, 'snapshots_processed' => $snapshotResult?->processed, 'snapshots_failed' => $snapshotResult?->failed, 'orphaned_deleted' => $orphanedDeleted, 'remote_timestamp' => $travelRemoteTimestamp, + 'failed_datasets' => $failedDatasets, + ]; + + if ([] !== $failedDatasets) { + $io->warning($successMessage); + $this->logger->warning('XML sync completed with dataset errors', $logContext); + } else { + $io->success($successMessage); + $this->logger->info('XML sync completed', $logContext); + } + + return Command::SUCCESS; + } + + if ([] !== $failedDatasets) { + $io->warning(sprintf( + 'Sync completed with errors for: %s', + implode(', ', $failedDatasets), + )); + $this->logger->warning('XML sync completed with dataset errors', [ + 'failed_datasets' => $failedDatasets, ]); return Command::SUCCESS; @@ -155,7 +175,7 @@ class BpnXmlSyncCommand extends Command } /** - * @return array{synced: bool, failed: bool, updated: int, deleted: int, travelSynced: bool, remoteTimestamp: ?string} + * @return array{dataset: string, synced: bool, failed: bool, updated: int, deleted: int, travelSynced: bool, remoteTimestamp: ?string} */ private function syncTarget( BpnXmlSyncTarget $target, @@ -175,6 +195,7 @@ class BpnXmlSyncCommand extends Command )); return [ + 'dataset' => $datasetName, 'synced' => false, 'failed' => false, 'updated' => 0, @@ -201,6 +222,7 @@ class BpnXmlSyncCommand extends Command ]); return [ + 'dataset' => $datasetName, 'synced' => false, 'failed' => false, 'updated' => 0, @@ -214,6 +236,7 @@ class BpnXmlSyncCommand extends Command $io->note(sprintf('[%s] Sync required (dry-run mode, no files downloaded)', $datasetName)); return [ + 'dataset' => $datasetName, 'synced' => false, 'failed' => false, 'updated' => 0, @@ -244,6 +267,7 @@ class BpnXmlSyncCommand extends Command ]); return [ + 'dataset' => $datasetName, 'synced' => false, 'failed' => true, 'updated' => 0, @@ -279,6 +303,7 @@ class BpnXmlSyncCommand extends Command ]); return [ + 'dataset' => $datasetName, 'synced' => true, 'failed' => false, 'updated' => $syncResult['updated'], diff --git a/tests/Command/BpnXmlSyncCommandTest.php b/tests/Command/BpnXmlSyncCommandTest.php index a8e9bda..3674079 100644 --- a/tests/Command/BpnXmlSyncCommandTest.php +++ b/tests/Command/BpnXmlSyncCommandTest.php @@ -12,6 +12,7 @@ use App\Service\TravelDataProvider; use App\Service\TravelSnapshotManager; use League\Flysystem\DirectoryListing; use League\Flysystem\FilesystemOperator; +use League\Flysystem\UnableToListContents; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; @@ -193,4 +194,84 @@ class BpnXmlSyncCommandTest extends TestCase $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); } + + public function testContingentTransferFailureDoesNotFailSuccessfulTravelSync(): void + { + $newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n"; + $olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n"; + + $this->xmlSource->method('read')->willReturn($newerTimestamp); + $this->xmlExport->method('fileExists')->willReturn(true); + $this->xmlExport->method('read')->willReturn($olderTimestamp); + $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([])); + $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([])); + + $this->xmlSourceContingents->method('read')->willReturn($newerTimestamp); + $this->xmlExportContingents->method('fileExists')->willReturn(true); + $this->xmlExportContingents->method('read')->willReturn($olderTimestamp); + $this->xmlSourceContingents->method('listContents') + ->willThrowException(UnableToListContents::atLocation('.', false, new \RuntimeException('SFTP unavailable'))); + + $fileMap = [ + 101 => ['hotels' => ['H1' => null]], + ]; + $this->travelLoader->expects($this->once()) + ->method('generateFilesMap') + ->willReturn($fileMap); + + $this->travelDataService->expects($this->once()) + ->method('syncSnapshotsFromXml') + ->with($fileMap, $this->isInstanceOf(\Closure::class)) + ->willReturn(['processed' => 1, 'failed' => 0]); + + $this->travelSnapshotService->expects($this->once()) + ->method('purgeOrphanedFutureSnapshots') + ->with([101]) + ->willReturn(0); + + $this->cache->expects($this->once()) + ->method('invalidateTags') + ->with(['xml-sync']); + + $tester = new CommandTester($this->command); + $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('[contingents] Sync failed', $tester->getDisplay()); + $this->assertStringContainsString('datasets: contingents', $tester->getDisplay()); + } + + public function testTravelTransferFailureDoesNotPreventContingentsSync(): void + { + $newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n"; + $olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n"; + + $this->xmlSource->method('read')->willReturn($newerTimestamp); + $this->xmlExport->method('fileExists')->willReturn(true); + $this->xmlExport->method('read')->willReturn($olderTimestamp); + $this->xmlSource->method('listContents') + ->willThrowException(UnableToListContents::atLocation('.', false, new \RuntimeException('SFTP unavailable'))); + + $this->xmlSourceContingents->method('read')->willReturn($newerTimestamp); + $this->xmlExportContingents->method('fileExists')->willReturn(true); + $this->xmlExportContingents->method('read')->willReturn($olderTimestamp); + $this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([])); + $this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([])); + + $this->travelLoader->expects($this->never())->method('generateFilesMap'); + $this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml'); + $this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots'); + + $this->cache->expects($this->once()) + ->method('invalidateTags') + ->with(['xml-sync']); + + $tester = new CommandTester($this->command); + $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('[travel] Sync failed', $tester->getDisplay()); + $this->assertStringContainsString('[contingents] Synced 0 files, deleted 0', $tester->getDisplay()); + $this->assertStringContainsString('datasets: travel', $tester->getDisplay()); + } }