From b27639c7fa09e3581b5f680c14419e813d2874a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 29 Sep 2026 14:19:24 +0200 Subject: [PATCH] fix: keep the travel date code to a single path segment The dateCode requirement added with the export route was `.+`, which replaces Symfony's default `[^/]+` and lets the segment span slashes. `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` was then matched by byCode() -- declared above it -- with dateCode swallowing all four segments, so availability answered 404 "Not found" instead of reaching the upstream call. Adds a routing test that reflects the controller's own attributes, so both the declaration order and the segment class stay guarded. Neither fails loudly on its own: a path falling through to the wrong route reads like an endpoint that exists and holds no data. Co-Authored-By: Claude Opus 5 --- src/Controller/Api/TravelController.php | 4 +- .../Api/TravelControllerRoutingTest.php | 97 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/Controller/Api/TravelControllerRoutingTest.php diff --git a/src/Controller/Api/TravelController.php b/src/Controller/Api/TravelController.php index 49535e0e..452097e2 100644 --- a/src/Controller/Api/TravelController.php +++ b/src/Controller/Api/TravelController.php @@ -99,7 +99,9 @@ class TravelController extends AbstractController #[Route( path: '/travels/{dateCode}/{hotelCode}', name: 'api_travel_single_code', - requirements: ['dateCode' => '(?!export$).+'], + // [^/]+ and not .+ : the default segment class must be kept, or dateCode swallows + // slashes and this route shadows api_travel_hotel_availability below it. + requirements: ['dateCode' => '(?!export$)[^/]+'], defaults: ['hotelCode' => null], )] public function byCode(Request $request, string $dateCode, ?string $hotelCode = null): JsonResponse diff --git a/tests/Controller/Api/TravelControllerRoutingTest.php b/tests/Controller/Api/TravelControllerRoutingTest.php new file mode 100644 index 00000000..1532a742 --- /dev/null +++ b/tests/Controller/Api/TravelControllerRoutingTest.php @@ -0,0 +1,97 @@ + + */ + 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(). + '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', + ); + } + + /** + * Reflects the controller's own #[Route] attributes, in declaration order. + */ + private function collection(): RouteCollection + { + $class = new \ReflectionClass(TravelController::class); + + $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(); + + $collection->add((string) $route->name, new Route( + $prefix.$route->path, + $route->defaults, + $route->requirements, + [], + '', + [], + $route->methods, + )); + } + } + + return $collection; + } +}