diff --git a/src/Http/Request/InteractsWithHeaders.php b/src/Http/Request/InteractsWithHeaders.php index f0f43e7..2cf4fbf 100644 --- a/src/Http/Request/InteractsWithHeaders.php +++ b/src/Http/Request/InteractsWithHeaders.php @@ -19,7 +19,7 @@ trait InteractsWithHeaders * * @return array */ - public function headers() + public function headers(): array { return array_map('sanitize_text_field', $this->rawHeaders()); } @@ -51,7 +51,7 @@ public function rawHeader(string $name, ?string $default = null) * * @return bool */ - public function hasHeader(string $name) + public function hasHeader(string $name): bool { return \array_key_exists(self::canonicalHeaderName($name), $this->rawHeaders()); } @@ -64,7 +64,7 @@ public function hasHeader(string $name) private function rawHeaders(): array { if (isset($this->rest) && $this->rest instanceof WP_REST_Request) { - return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers()); + return array_map(static fn ($values): string => implode(', ', (array) $values), (array) $this->rest->get_headers()); } return $this->serverHeaders(); diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php index b9018e9..cf09340 100644 --- a/src/Http/Router/RouteRegister.php +++ b/src/Http/Router/RouteRegister.php @@ -526,7 +526,7 @@ private function invokeAsReflectionFunction(callable $method): mixed return $reflectionFunction->invoke(...$params); } - private function processParameters($reflectionParams, array $params = []): array + private function processParameters(array $reflectionParams, array $params = []): array { $requestParams = []; foreach ($reflectionParams as $param) { diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 3a3963e..9c5ead4 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -215,12 +215,20 @@ private function routePath(RouteRegister $route): string $prefix = trim((string) $route->getRoutePrefix(), '/'); $path = trim((string) $route->getPath(), '/'); - return $prefix === '' ? $path : $prefix . '/' . $path; + return self::joinPath($prefix, $path); + } + + /** + * Joins path segments with '/', skipping empty ones so a root route leaves no trailing slash. + */ + private static function joinPath(string ...$segments): string + { + return implode('/', array_filter($segments, static fn (string $segment): bool => $segment !== '')); } private function isRouteMatched(RouteRegister $route, string $requestPath): bool { - $path = $this->pageName . '/' . $this->routePath($route); + $path = self::joinPath($this->pageName, $this->routePath($route)); $compiled = RoutePattern::compile($path); $pattern = $compiled === null ? preg_quote($path, '~') : $compiled['regex']; diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index fae7889..8add960 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -558,13 +558,109 @@ public function testRootRuleCoexistsWithOtherRoutes(): void assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'route rule missing next to the root rule'); } - private function makeTransport(array $routes = []): StaticRouter + public function testRootRouteMatchesTheParsedPageRoot(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $this->setParsedRequest('landing'); + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match the parsed page root'); + } + + public function testRootRouteMatchesThePageRootOnSubdirectoryInstalls(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing'; + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match on a subdirectory install'); + } + + public function testRootRouteMatchesThePageRootFromTheRequestUri(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $_SERVER['REQUEST_URI'] = '/landing'; + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match a page root without a trailing slash'); + } + + public function testRootRouteMatchesThePageRootFromATrailingSlashRequestUri(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $_SERVER['REQUEST_URI'] = '/landing/?preview=1'; + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match a page root with a trailing slash'); + } + + public function testRootRouteDoesNotMatchDeeperPaths(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $this->setParsedRequest('landing/entries/42'); + + do_action('template_redirect'); + + assertTest(!isset(WpKitTestState::$filters['the_content']), 'root route matched a deeper path'); + } + + public function testRootRouteDoesNotMatchAPageSharingItsNamePrefix(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $this->setParsedRequest('landing-archive'); + + do_action('template_redirect'); + + assertTest(!isset(WpKitTestState::$filters['the_content']), 'root route matched another page sharing its name prefix'); + } + + public function testRootRouteDoesNotShadowSiblingRoutes(): void + { + $this->makeTransport([ + '/' => static fn () => 'home', + 'user/{id}' => static fn ($id) => 'user ' . $id, + ]); + $this->setParsedRequest('landing/user/5'); + + do_action('template_redirect'); + + assertSameValue('page-user 5', apply_filters('the_content', 'page-'), 'root route shadowed a sibling route'); + } + + public function testEmptyPathInsideAPrefixMatchesThePrefixRoot(): void + { + $this->makeTransport(['/' => static fn () => 'account'], 'account'); + $this->setParsedRequest('landing/account'); + + do_action('template_redirect'); + + assertSameValue('page-account', apply_filters('the_content', 'page-'), 'empty path inside a prefix did not match the prefix root'); + } + + /** + * Registers the given GET routes, optionally under a prefix, on a static router for the landing page. + */ + private function makeTransport(array $routes = [], ?string $prefix = null): StaticRouter { new Router('static', 'landing', null); foreach ($routes as $path => $action) { - (new RouteBase())->get($path, $action); + (new RouteBase())->prefix($prefix)->get($path, $action); } return new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); } + + /** + * Sets the parsed WP request path that the router reads from the global WP object. + */ + private function setParsedRequest(string $path): void + { + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = $path; + } }