/api/travels/{dateCode} accepts an arbitrary string, so /api/travels/export
shared a namespace with every date code and stayed reachable only because
byCode() carried a negative lookahead ruling that one string out. Nothing
in the URL announced the constraint, and getting it wrong mis-routed
silently: the first attempt widened the segment to `.+` and swallowed
/api/travels/{dateId}/{hotelId}/{dateTo}/availability whole.
/api/exports never takes a parameterised segment, the same shape as
/api/contingents, so the collision stops existing instead of being
guarded. TravelController returns byte-for-byte to its state on master,
lookahead included, and the export moves to its own resource controller.
The routing test now also asserts that nothing is reintroduced under the
date code namespace.
Co-Authored-By: Claude Opus 5 <[email protected]>
133 lines
4.6 KiB
PHP
133 lines
4.6 KiB
PHP
<?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;
|
|
}
|
|
}
|