call(new ExportQuery(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 ExportQuery(1, 5))['meta']; $last = $this->call(new ExportQuery(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 ExportQuery(1, 5))['data'], 'busProId'); $second = array_column($this->call(new ExportQuery(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 ExportQuery(3, 5))['data']; self::assertCount(2, $data); self::assertSame([11, 12], array_column($data, 'busProId')); } public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void { $body = $this->call(new ExportQuery(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 Export([], 0, 0, new \DateTimeImmutable())); $meta = json_decode((string) $this->controller($builder)->travels(new ExportQuery())->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 ExportQuery()); } public function testProductsUseTheSameEnvelopeAndPaging(): void { $rows = []; for ($i = 1; $i <= 3; ++$i) { $rows[] = ['date' => ['busProId' => $i], 'hotel' => ['busProId' => 1]]; } $productBuilder = $this->createStub(ProductExportBuilder::class); $productBuilder->method('build')->willReturn(new Export( $rows, 2, 5, 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($this->createStub(TravelExportBuilder::class), $productBuilder) ->products(new ExportQuery(2, 2)); $body = json_decode((string) $response->getContent(), true); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame([3], array_column(array_column($body['data'], 'date'), 'busProId')); self::assertSame([ 'page' => 2, 'perPage' => 2, 'total' => 3, 'totalPages' => 2, 'skipped' => 2, 'pickupsWithoutMasterData' => 5, 'sourceGeneratedAt' => '2026-09-28T23:10:15+02:00', 'generatedAt' => '2026-09-29T03:20:00+02:00', ], $body['meta']); } public function testAProductBuilderFailureIsNotDowngradedToATwoHundred(): void { $productBuilder = $this->createStub(ProductExportBuilder::class); $productBuilder->method('build')->willThrowException(new \RuntimeException('cache exploded')); $this->expectException(\RuntimeException::class); $this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)->products(new ExportQuery()); } /** * @return array */ private function call(?ExportQuery $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 Export( $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 ExportQuery()); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); return json_decode((string) $response->getContent(), true); } private function controller(TravelExportBuilder $builder, ?ProductExportBuilder $productBuilder = null): ExportController { $controller = new ExportController($builder, $productBuilder ?? $this->createStub(ProductExportBuilder::class)); // 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; } }