refactor: move the bulk export to its own /api/exports resource

/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]>
This commit is contained in:
frommeandClaude Opus 5 committed 2026-09-29 14:23:36 +02:00
1 parent b27639c7fa
commit 3221008f90
5 files changed
+130 -80

No files matched your search

@@ -4,6 +4,7 @@ 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;
@@ -14,11 +15,13 @@ use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Guards the ordering and the segment requirements of the /api/travels routes.
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
*
* Both are load-bearing and neither fails loudly: a path that falls through to the wrong
* route answers 404 with a body that reads like an endpoint holding no data. The collection
* is built by reflecting over the real attributes, so this breaks if the declarations move.
* `/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
{
@@ -28,9 +31,9 @@ class TravelControllerRoutingTest extends TestCase
public static function pathProvider(): array
{
return [
'the export route wins over the code lookup' => ['/api/travels/export', 'api_travel_export'],
// Regression: a dateCode requirement of `.+` instead of `[^/]+` widens the segment
// across slashes, and this four-segment path is then swallowed by byCode().
'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',
@@ -62,20 +65,47 @@ class TravelControllerRoutingTest extends TestCase
);
}
/**
* 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
{
$class = new \ReflectionClass(TravelController::class);
$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;
}
$collection = new RouteCollection();
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
$route = $attribute->newInstance();
@@ -91,7 +121,5 @@ class TravelControllerRoutingTest extends TestCase
));
}
}
return $collection;
}
}