*/ 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, )); } } } }