From 272c811f4f4c782b9b6f9c760da20f1aa56148df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 30 Sep 2026 12:09:57 +0200 Subject: [PATCH] feat: report the source export timestamp on every travel export page --- docs/api-consumer-guide.md | 10 ++++++++-- src/Controller/Api/ExportController.php | 1 + src/Model/TravelExport.php | 2 ++ src/Service/TravelExportBuilder.php | 7 +++++++ tests/Controller/Api/ExportControllerTest.php | 15 ++++++++++++++- tests/Service/TravelExportBuilderTest.php | 19 +++++++++++++++++-- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index f361fe47..0297e5f5 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -202,6 +202,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`. "totalPages": 1, "skipped": 0, "pickupsWithoutMasterData": 0, + "sourceGeneratedAt": "2026-09-29T11:11:01+02:00", "generatedAt": "2026-09-29T12:10:06+02:00" } } @@ -211,8 +212,8 @@ an error: it returns `200` with an empty `data` array and the usual `meta`. is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly empty, never null. -**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData` -and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the +**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`, +`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`. - `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent @@ -240,6 +241,11 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total under one cache tag, so all three move together on an upstream sync rather than drifting between your calls. It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it. +**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the +same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can +replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on +page 1, you have stitched two exports together. Discard what you have and restart from page 1. + --- ### Travels diff --git a/src/Controller/Api/ExportController.php b/src/Controller/Api/ExportController.php index 0052a892..f3f8c0c8 100644 --- a/src/Controller/Api/ExportController.php +++ b/src/Controller/Api/ExportController.php @@ -59,6 +59,7 @@ class ExportController extends AbstractController 'totalPages' => (int) ceil($total / $query->perPage), 'skipped' => $export->skipped, 'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData, + 'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'), 'generatedAt' => $export->generatedAt->format('c'), ], ]); diff --git a/src/Model/TravelExport.php b/src/Model/TravelExport.php index ab8a2758..e423ef08 100644 --- a/src/Model/TravelExport.php +++ b/src/Model/TravelExport.php @@ -19,12 +19,14 @@ final readonly class TravelExport * @param list> $rows one row per (travel, hotel) pair * @param int $skipped (travel, hotel) pairs dropped as unusable * @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml + * @param \DateTimeImmutable|null $sourceGeneratedAt the uebertragung.info timestamp of the export read, null if unknown */ public function __construct( public array $rows, public int $skipped, public int $pickupsWithoutMasterData, public \DateTimeImmutable $generatedAt, + public ?\DateTimeImmutable $sourceGeneratedAt = null, ) { } } diff --git a/src/Service/TravelExportBuilder.php b/src/Service/TravelExportBuilder.php index da18bb57..ad7d88b5 100644 --- a/src/Service/TravelExportBuilder.php +++ b/src/Service/TravelExportBuilder.php @@ -6,6 +6,7 @@ namespace App\Service; use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Pickup; +use App\BusProNet\Model\XmlExportInfo; use App\BusProNet\Traits\TypeConversionTrait; use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlLoader\HotelLoader; @@ -67,6 +68,11 @@ class TravelExportBuilder private function assemble(): TravelExport { + // Read before the walk and cached with the rows, so every page served from this + // copy names the same source export. A client that sees it change between pages + // knows xml-sync swapped the export underneath its walk. + $sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer; + $hotels = $this->hotelLoader->loadAll(); $pickupMasterData = $this->pickupLoader->loadAll(); @@ -96,6 +102,7 @@ class TravelExportBuilder $skipped, count($pickupsWithoutMasterData), new \DateTimeImmutable(), + $sourceGeneratedAt, ); } diff --git a/tests/Controller/Api/ExportControllerTest.php b/tests/Controller/Api/ExportControllerTest.php index 874f24ea..4f7aa7bf 100644 --- a/tests/Controller/Api/ExportControllerTest.php +++ b/tests/Controller/Api/ExportControllerTest.php @@ -30,7 +30,7 @@ class ExportControllerTest extends TestCase $first = $this->call(new TravelExportQuery(1, 5))['meta']; $last = $this->call(new TravelExportQuery(3, 5))['meta']; - foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) { + foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) { self::assertSame($first[$key], $last[$key], $key.' must not vary by page'); } } @@ -78,6 +78,18 @@ class ExportControllerTest extends TestCase self::assertSame(7, $meta['skipped']); self::assertSame(21, $meta['pickupsWithoutMasterData']); self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']); + self::assertSame('2026-09-28T23:10:15+02:00', $meta['sourceGeneratedAt']); + } + + public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void + { + $builder = $this->createStub(TravelExportBuilder::class); + $builder->method('build')->willReturn(new TravelExport([], 0, 0, new \DateTimeImmutable())); + + $meta = json_decode((string) $this->controller($builder)->travels(new TravelExportQuery())->getContent(), true)['meta']; + + self::assertArrayHasKey('sourceGeneratedAt', $meta); + self::assertNull($meta['sourceGeneratedAt']); } /** @@ -110,6 +122,7 @@ class ExportControllerTest extends TestCase $skipped, $withoutMasterData, 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($builder)->travels($query ?? new TravelExportQuery()); diff --git a/tests/Service/TravelExportBuilderTest.php b/tests/Service/TravelExportBuilderTest.php index d8774162..384ab862 100644 --- a/tests/Service/TravelExportBuilderTest.php +++ b/tests/Service/TravelExportBuilderTest.php @@ -156,6 +156,18 @@ class TravelExportBuilderTest extends TestCase /** * @return array */ + public function testCarriesTheSourceExportTimestamp(): void + { + $export = $this->build(null, ['Ziel_1.xml'], "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')); + } + + public function testTheSourceExportTimestampIsNullWithoutAMarker(): void + { + self::assertNull($this->build()->sourceGeneratedAt); + } + private function rowFor(string $key): array { foreach ($this->build()->rows as $row) { @@ -170,7 +182,7 @@ class TravelExportBuilderTest extends TestCase /** * @param list $files */ - private function build(?string $xml = null, array $files = ['Ziel_1.xml']): TravelExport + private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): TravelExport { $contents = [ 'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'), @@ -183,7 +195,10 @@ class TravelExportBuilderTest extends TestCase $files, ))); $filesystem->method('read')->willReturnCallback( - static fn (string $path): string => $contents[$path] ?? '' + static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? '' + ); + $filesystem->method('fileExists')->willReturnCallback( + static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker ); $hotelLoader = $this->createStub(HotelLoader::class);