feat: add a paginated travel export for the teamer app

This commit is contained in:
fromme committed 2026-09-29 14:46:36 +02:00
1 parent 6b539f43f3
commit 7dadb6806a
10 files changed
+915

No files matched your search

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Model\TravelExport;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
class ExportControllerTest extends TestCase
{
public function testPaginatesAndReportsWholeExportFigures(): void
{
$meta = $this->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', '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']);
}
/**
* 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<string, mixed>
*/
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')),
));
$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;
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Controller\Api\TravelController;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
*
* `/api/travels/{dateCode}` accepts an arbitrary string, so anything else placed under
* `/api/travels/` competes with every date code, and a path falling through to the wrong
* route answers 404 with a body that reads like an endpoint holding no data. The bulk
* export lives at `/api/exports/travels` for exactly this reason. The collection is built
* by reflecting over the real attributes, so this breaks if the declarations change.
*/
class TravelControllerRoutingTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function pathProvider(): array
{
return [
'the export lives outside the travels namespace' => ['/api/exports/travels', 'api_exports_travels'],
// Regression: a dateCode requirement of `.+` instead of the default `[^/]+` widens
// the segment across slashes, and this four-segment path is then swallowed by byCode().
'availability is not shadowed by the code lookup' => [
'/api/travels/12794/225444/2026-12-30/availability',
'api_travel_hotel_availability',
],
'numeric ids still reach byId' => ['/api/travels/12794/225444', 'api_travel_single_id'],
'a date code alone reaches byCode' => ['/api/travels/SBW261226', 'api_travel_single_code'],
'a date and hotel code reach byCode' => ['/api/travels/SBW261226/H1', 'api_travel_single_code'],
'the mapping index is untouched' => ['/api/travels', 'api_travel_mapping'],
];
}
#[DataProvider('pathProvider')]
public function testPathResolvesToTheIntendedRoute(string $path, string $expected): void
{
$matcher = new UrlMatcher($this->collection(), new RequestContext('', 'GET'));
self::assertSame($expected, $matcher->match($path)['_route']);
}
public function testDateCodeNeverSpansSlashes(): void
{
$route = $this->collection()->get('api_travel_single_code');
self::assertNotNull($route);
self::assertStringNotContainsString(
'.+',
(string) $route->getRequirement('dateCode'),
'dateCode must keep the single-segment class, or it shadows the routes below it',
);
}
/**
* The export must stay out of `/api/travels/`, where it would be one date code away from
* ambiguity and would need a negative lookahead on byCode() to be reachable at all.
*/
public function testNothingButTravelLookupsLivesUnderTheTravelsNamespace(): void
{
foreach ($this->collection()->all() as $name => $route) {
self::assertStringNotContainsString(
'/api/travels/export',
$route->getPath(),
$name.' must not sit in the date code namespace',
);
}
}
/**
* Reflects the controller's own #[Route] attributes, in declaration order.
*/
private function collection(): RouteCollection
{
$collection = new RouteCollection();
foreach ([TravelController::class, ExportController::class] as $controller) {
$this->addRoutes($collection, $controller);
}
return $collection;
}
/**
* @param class-string $controller
*/
private function addRoutes(RouteCollection $collection, string $controller): void
{
$class = new \ReflectionClass($controller);
$prefix = '';
foreach ($class->getAttributes(RouteAttribute::class) as $attribute) {
$prefix = (string) $attribute->newInstance()->path;
}
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
$route = $attribute->newInstance();
$collection->add((string) $route->name, new Route(
$prefix.$route->path,
$route->defaults,
$route->requirements,
[],
'',
[],
$route->methods,
));
}
}
}
}