/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]>
126 lines
4.6 KiB
PHP
126 lines
4.6 KiB
PHP
<?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,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|