call(new TravelExportQuery(1, 5))['meta']; self::assertSame(1, $meta['page']); self::assertSame(5, $meta['perPage']); self::assertSame(12, $meta['total'], 'total is the whole export, not this page'); self::assertSame(3, $meta['totalPages']); } public function testCountersAreIdenticalOnEveryPage(): void { // Whole-export figures repeated per page, so a consumer can abort after page 1. $first = $this->call(new TravelExportQuery(1, 5))['meta']; $last = $this->call(new TravelExportQuery(3, 5))['meta']; foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) { self::assertSame($first[$key], $last[$key], $key.' must not vary by page'); } } public function testConsecutivePagesDoNotOverlap(): void { $first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId'); $second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId'); self::assertSame([1, 2, 3, 4, 5], $first); self::assertSame([6, 7, 8, 9, 10], $second); self::assertSame([], array_intersect($first, $second)); } public function testTheLastPageIsShort(): void { $data = $this->call(new TravelExportQuery(3, 5))['data']; self::assertCount(2, $data); self::assertSame([11, 12], array_column($data, 'busProId')); } public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void { $body = $this->call(new TravelExportQuery(99, 5)); self::assertSame([], $body['data']); self::assertSame(12, $body['meta']['total']); } public function testDefaultsToTheFirstPageOfFiveHundred(): void { $body = $this->call(); self::assertSame(1, $body['meta']['page']); self::assertSame(500, $body['meta']['perPage']); self::assertSame(1, $body['meta']['totalPages']); self::assertCount(12, $body['data']); } public function testCarriesTheBuilderCounters(): void { $meta = $this->call(null, 7, 21)['meta']; 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']); } /** * A builder failure must reach the consumer as a 5xx, never as the 200-with-an-error-body * shape TravelController::hotelAvailability() uses: the retry logic keys on the status code. */ public function testABuilderFailureIsNotDowngradedToATwoHundred(): void { $builder = $this->createStub(TravelExportBuilder::class); $builder->method('build')->willThrowException(new \RuntimeException('cache exploded')); $this->expectException(\RuntimeException::class); $this->controller($builder)->travels(new TravelExportQuery()); } /** * @return array */ private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array { $rows = []; for ($i = 1; $i <= 12; ++$i) { $rows[] = ['busProId' => $i, 'hotel' => ['busProId' => 1], 'pickups' => []]; } $builder = $this->createStub(TravelExportBuilder::class); $builder->method('build')->willReturn(new TravelExport( $rows, $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()); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); return json_decode((string) $response->getContent(), true); } private function controller(TravelExportBuilder $builder): ExportController { $controller = new ExportController($builder); // No 'serializer' service registered, so AbstractController::json() falls back to // JsonResponse -- which is what this endpoint produces in production anyway. $controller->setContainer(new Container()); return $controller; } }