*/ public static function mounts(): array { // SCRIPT_NAME REQUEST_URI expected return [ 'web root' => ['/index.php', '/login', '/login'], 'web root, query' => ['/index.php', '/api/events?since=5', '/api/events'], 'web root, bare' => ['/index.php', '/', '/'], 'subdir' => ['/move_detect/index.php', '/move_detect/login', '/login'], 'subdir, query' => ['/move_detect/index.php', '/move_detect/api/events?since=5', '/api/events'], 'subdir, bare' => ['/move_detect/index.php', '/move_detect/', '/'], 'subdir, no slash' => ['/move_detect/index.php', '/move_detect', '/'], 'subdir, param route' => ['/move_detect/index.php', '/move_detect/events/42', '/events/42'], 'nested subdir' => ['/a/b/index.php', '/a/b/api/csrf', '/api/csrf'], ]; } #[DataProvider('mounts')] public function testBasePathIsStrippedFromTheRoutedPath( string $scriptName, string $requestUri, string $expected): void { $this->assertSame($expected, Request::pathFor($requestUri, Request::baseFrom($scriptName))); } public function testBaseFromScriptName(): void { $this->assertSame('', Request::baseFrom('/index.php')); $this->assertSame('/move_detect', Request::baseFrom('/move_detect/index.php')); $this->assertSame('/a/b', Request::baseFrom('/a/b/index.php')); // Missing/odd SCRIPT_NAME must degrade to "mounted at the root", never to a prefix // that would silently swallow the first path segment of every request. $this->assertSame('', Request::baseFrom('')); $this->assertSame('', Request::baseFrom('index.php')); } public function testAPathThatMerelyResemblesTheBaseIsNotTruncated(): void { // '/move_detected/x' starts with the literal string '/move_detect' but is NOT inside // the mount. A naive str_starts_with() would rewrite it to 'ed/x'. $base = Request::baseFrom('/move_detect/index.php'); $this->assertSame('/move_detected/x', Request::pathFor('/move_detected/x', $base)); } public function testRouterMatchesDeclaredRoutesWhenMountedInASubdirectory(): void { $r = new Router(); $r->add('GET', '/api/events', fn() => Response::json(['ok' => true])); $r->add('GET', '/events/{id}', fn($q, $p) => Response::json(['id' => $p['id']])); $base = Request::baseFrom('/move_detect/index.php'); $list = new Request('GET', Request::pathFor('/move_detect/api/events', $base), [], ''); $this->assertSame(200, $r->dispatch($list)->status()); $detail = new Request('GET', Request::pathFor('/move_detect/events/42', $base), [], ''); $this->assertSame(['id' => '42'], json_decode($r->dispatch($detail)->body(), true)); } public function testRouteParamsAreRawUrlDecoded(): void { // A camera_id with a space or Korean characters is %-encoded in the URL. Without // decoding the captured param, GET /api/zones/{camera} can never match what // POST /api/zones saved — zones save but never load back. $r = new Router(); $r->add('GET', '/api/zones/{camera}', fn($q, $p) => Response::json(['camera' => $p['camera']])); $res = $r->dispatch(new Request('GET', '/api/zones/cam%20A', [], '')); $this->assertSame('cam A', json_decode($res->body(), true)['camera']); $res2 = $r->dispatch(new Request('GET', '/api/zones/%EA%B0%80', [], '')); // "가" $this->assertSame('가', json_decode($res2->body(), true)['camera']); } public function testFromGlobalsHonoursScriptNameWithoutTrustingClientInput(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; $_SERVER['SCRIPT_NAME'] = '/move_detect/index.php'; $_SERVER['REQUEST_URI'] = '/move_detect/zones'; $this->assertSame('/zones', Request::fromGlobals()->path()); } }