bind($class, $controller); return new Router($resolver); } /** * Baseline: duplicated parameter names produce a route that can never * match, and emit a PHP warning on every match attempt. * * Desired (miniroute-uhs.1): registration throws * RouteRegistrationException. Flip this test when the fix lands. */ public function testDuplicateParamNamesAreADeadRoute(): void { $router = $this->routerWith(new DuplicateParamController(), DuplicateParamController::class); $router->registerController(DuplicateParamController::class); $warnings = []; set_error_handler(static function (int $errno, string $errstr) use (&$warnings): bool { $warnings[] = $errstr; return true; }); try { $router->dispatch(new TestRequest('GET', '/dup/1/x/2')); $matched = true; } catch (RouteNotFoundException) { $matched = false; } finally { restore_error_handler(); } $this->assertFalse($matched, 'duplicate-param route must not match'); $this->assertNotEmpty($warnings, 'matching a duplicate-param route emits a preg warning'); } /** * Baseline: a wrong method on an existing path is a 404, with no way to * distinguish method-not-allowed. * * Desired (miniroute-uhs.2): MethodNotAllowedException carrying the * allowed methods, so the app can render 405 + Allow. Flip this test. */ public function testWrongMethodOnExistingPathIsNotFound(): void { $router = $this->routerWith(new OnlyGetController(), OnlyGetController::class); $router->registerController(OnlyGetController::class); $this->expectException(RouteNotFoundException::class); $router->dispatch(new TestRequest('POST', '/only')); } /** * Baseline: HEAD requests never map to GET handlers. * * Desired (miniroute-uhs.3): HEAD dispatches to the GET handler and the * app's response layer suppresses the body. Flip this test. */ public function testHeadDoesNotMapToGet(): void { $router = $this->routerWith(new OnlyGetController(), OnlyGetController::class); $router->registerController(OnlyGetController::class); $this->expectException(RouteNotFoundException::class); $router->dispatch(new TestRequest('HEAD', '/only')); } /** * Baseline: route attributes on non-public methods are silently skipped * by the loader. * * Desired (miniroute-uhs.4): loading a controller with a route attribute * on a non-public method throws RouteRegistrationException. Flip this test. */ public function testNonPublicMethodsWithRouteAttributesAreSkipped(): void { $routes = (new RouteLoader())->load(MixedVisibilityController::class); $this->assertCount(1, $routes); $this->assertSame('/pub', $routes[0]->path); } /** * Baseline: a resolver returning the same middleware instance across * requests accumulates state (singleton-container leak). This is a usage * contract, not a kernel bug: middleware must be stateless or * per-request scoped. ADR work tracked in miniroute-k4i.3. */ public function testSingletonMiddlewareAccumulatesStateAcrossRequests(): void { $stateful = new StatefulMiddleware(); $resolver = new FakeResolver(); $resolver->bind(StatefulController::class, new StatefulController()); $resolver->bind(StatefulMiddleware::class, $stateful); $router = new Router($resolver); $router->registerController(StatefulController::class); $router->dispatch(new TestRequest('GET', '/stateful')); $router->dispatch(new TestRequest('GET', '/stateful')); $this->assertSame(2, $stateful->count, 'state leaks across dispatches with a shared instance'); } /** * Baseline: {param} segments are greedy — any single segment matches, so * a typo'd URL gets a 200 from the parameterized route, not a 404. * * Documented behavior, no kernel fix planned: controllers validate the * captured value (the blog controller 404s on an unknown slug). Decision * recorded in miniroute-k4i.3 (param constraints rejected). */ public function testParamRoutesAreGreedy(): void { $router = $this->routerWith(new FakeController(), FakeController::class); $router->registerController(FakeController::class); $response = $router->dispatch(new TestRequest('GET', '/entries/typo-of-known-path')); $this->assertSame(200, $response->status()); $this->assertSame('show:typo-of-known-path', $response->body()); } }