use super::*;
use hyper::Method;
use reinhardt_core::endpoint::EndpointInfo;
use reinhardt_http::{Handler, Request, Response, Result};
use rstest::rstest;
use std::sync::Arc;
struct TestEndpoint<const ID: u8>;
impl<const ID: u8> EndpointInfo for TestEndpoint<ID> {
fn path() -> &'static str {
match ID {
1 => "/health",
2 => "/list",
3 => "/users/{id}",
4 => "/users/{id}/posts",
5 => "/posts/{post_id}/comments/{comment_id}",
6 => "/orgs/{org}/clusters/{cluster_id}/",
7 => "/users",
8 => "/posts",
9 => "/items",
10 => "/api/users/",
11 => "/api/server_fn/test",
12 => "/api/users",
13 => "/auth/register/",
14 => "/auth/login/",
15 => "/users/",
16 => "/dashboard/",
17 => "/api/health",
18 => "/sub/action/",
19 => "/profile/",
20 => "/detail/",
21 => "/edit/",
22 => "/catch/",
23 => "/users",
24 => "/items",
25 => "/users",
26 => "/items",
27 => "/users",
28 => "/profile",
_ => unreachable!("unsupported test endpoint"),
}
}
fn method() -> Method {
match ID {
8 | 11 | 12 | 13 | 14 | 18 | 27 => Method::POST,
21 => Method::PUT,
_ => Method::GET,
}
}
fn name() -> &'static str {
match ID {
1 => "health",
2 => "list",
3 => "users-detail",
4 => "users-posts",
5 => "post-comment",
6 => "cluster-detail",
7 => "users-list",
8 => "posts-create",
9 => "items-list",
10 => "api-users",
11 => "server-fn-test",
12 => "api-users-create",
13 => "auth-register",
14 => "auth-login",
15 => "users-index",
16 => "dashboard",
17 => "api-health",
18 => "sub-action",
19 => "profile",
20 => "detail",
21 => "edit",
22 => "catch",
23 => "list",
24 => "list",
25 => "users-list",
26 => "items-list",
27 => "users-create",
28 => "!profile_detail",
_ => unreachable!("unsupported test endpoint"),
}
}
}
#[async_trait::async_trait]
impl<const ID: u8> Handler for TestEndpoint<ID> {
async fn handle(&self, _req: Request) -> Result<Response> {
Ok(Response::ok())
}
}
#[rstest]
fn test_new_router() {
let router = ServerRouter::new();
assert_eq!(router.prefix(), "");
assert_eq!(router.namespace(), None);
assert_eq!(router.children_count(), 0);
}
#[rstest]
fn test_with_prefix() {
let router = ServerRouter::new().with_prefix("/api/v1");
assert_eq!(router.prefix(), "/api/v1");
}
#[rstest]
fn test_with_namespace() {
let router = ServerRouter::new().with_namespace("v1");
assert_eq!(router.namespace(), Some("v1"));
}
#[rstest]
fn test_mount() {
let child = ServerRouter::new();
let router = ServerRouter::new().mount("/users/", child);
assert_eq!(router.children_count(), 1);
}
#[rstest]
#[should_panic(expected = "path parameter placeholder")]
fn test_mount_panics_on_param_prefix() {
let child = ServerRouter::new();
let _ = ServerRouter::new().mount("/orgs/{org}/clusters/", child);
}
#[rstest]
fn test_mount_inherits_di_context() {
let di_ctx =
Arc::new(InjectionContext::builder(Arc::new(reinhardt_di::SingletonScope::new())).build());
let child = ServerRouter::new();
let router = ServerRouter::new()
.with_di_context(di_ctx.clone())
.mount("/users/", child);
assert!(router.di_context.is_some());
assert_eq!(router.children_count(), 1);
}
#[rstest]
fn test_group() {
let users = ServerRouter::new().with_prefix("/users");
let posts = ServerRouter::new().with_prefix("/posts");
let router = ServerRouter::new().group(vec![users, posts]);
assert_eq!(router.children_count(), 2);
}
#[rstest]
fn test_get_all_routes() {
let router = ServerRouter::new()
.with_prefix("/api")
.with_namespace("api");
let routes = router.get_all_routes();
assert_eq!(routes.len(), 0);
}
#[rstest]
fn test_get_all_routes_strips_optout_sigil_from_endpoint_name() {
let router = ServerRouter::new().endpoint(|| TestEndpoint::<28>);
let routes = router.get_all_routes();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].1.as_deref(), Some("profile_detail"));
}
#[rstest]
fn test_get_full_namespace_no_parent() {
let router = ServerRouter::new().with_namespace("users");
assert_eq!(router.get_full_namespace(None), Some("users".to_string()));
}
#[rstest]
fn test_get_full_namespace_with_parent() {
let router = ServerRouter::new().with_namespace("users");
assert_eq!(
router.get_full_namespace(Some("v1")),
Some("v1:users".to_string())
);
}
#[rstest]
fn test_get_full_namespace_no_namespace() {
let router = ServerRouter::new();
assert_eq!(
router.get_full_namespace(Some("v1")),
Some("v1".to_string())
);
assert_eq!(router.get_full_namespace(None), None);
}
#[rstest]
fn test_hierarchical_namespace() {
let child = ServerRouter::new().with_namespace("users");
let parent = ServerRouter::new()
.with_namespace("v1")
.mount("/users/", child);
assert_eq!(parent.namespace(), Some("v1"));
assert_eq!(parent.children_count(), 1);
}
#[rstest]
fn test_register_all_routes_with_namespace() {
let mut router = ServerRouter::new()
.with_namespace("api")
.endpoint(|| TestEndpoint::<1>);
let errors = router.register_all_routes();
assert!(errors.is_empty());
let url = router.reverse("api:health", &[]);
assert!(url.is_some());
assert_eq!(url.unwrap(), "/health");
}
#[rstest]
fn test_nested_namespace_registration() {
let users = ServerRouter::new()
.with_namespace("users")
.endpoint(|| TestEndpoint::<2>);
let mut api = ServerRouter::new()
.with_namespace("v1")
.with_prefix("/api/v1")
.mount("/users/", users);
let errors = api.register_all_routes();
assert!(errors.is_empty());
let url = api.reverse("v1:users:list", &[]);
assert!(url.is_some());
assert_eq!(url.unwrap(), "/api/v1/users/list");
}
#[rstest]
fn test_mount_prefix_inheritance() {
let child = ServerRouter::new();
let parent = ServerRouter::new().with_prefix("/api").mount("/v1/", child);
assert_eq!(parent.children_count(), 1);
}
#[rstest]
fn test_multiple_child_routers() {
let users = ServerRouter::new().with_namespace("users");
let posts = ServerRouter::new().with_namespace("posts");
let comments = ServerRouter::new().with_namespace("comments");
let router = ServerRouter::new()
.mount("/users/", users)
.mount("/posts/", posts)
.mount("/comments/", comments);
assert_eq!(router.children_count(), 3);
}
#[rstest]
fn test_deep_nesting() {
let resource = ServerRouter::new().with_namespace("resource");
let v2 = ServerRouter::new()
.with_namespace("v2")
.mount("/resource/", resource);
let v1 = ServerRouter::new().with_namespace("v1").mount("/v2/", v2);
let api = ServerRouter::new().with_namespace("api").mount("/v1/", v1);
assert_eq!(api.children_count(), 1);
}
#[tokio::test]
async fn test_route_matching_correctness() {
let router = ServerRouter::new()
.endpoint(|| TestEndpoint::<3>)
.endpoint(|| TestEndpoint::<4>)
.endpoint(|| TestEndpoint::<5>);
router.compile_routes();
let result = router.match_own_routes("/users/123", &Method::GET);
assert!(result.is_some());
assert_eq!(result.unwrap().param("id"), Some("123"));
let result = router.match_own_routes("/users/456/posts", &Method::GET);
assert!(result.is_some());
assert_eq!(result.unwrap().param("id"), Some("456"));
let route_match = router.match_own_routes("/posts/789/comments/101", &Method::GET);
let route_match = route_match.unwrap();
assert_eq!(route_match.param("post_id"), Some("789"));
assert_eq!(route_match.param("comment_id"), Some("101"));
assert_eq!(
route_match.params.as_slice(),
&[
("post_id".to_string(), "789".to_string()),
("comment_id".to_string(), "101".to_string()),
],
"path params must be stored in URL pattern declaration order (issue #4013)"
);
let result = router.match_own_routes("/nonexistent", &Method::GET);
assert!(result.is_none());
}
#[tokio::test]
async fn test_route_matching_preserves_url_pattern_order_issue_4013() {
let router = ServerRouter::new().endpoint(|| TestEndpoint::<6>);
router.compile_routes();
let route_match = router
.match_own_routes("/orgs/myslug/clusters/5/", &Method::GET)
.expect("route should match");
assert_eq!(
route_match.params.as_slice(),
&[
("org".to_string(), "myslug".to_string()),
("cluster_id".to_string(), "5".to_string()),
],
"matched params must follow URL declaration order (issue #4013)"
);
}
#[tokio::test]
async fn test_route_matching_different_methods() {
let router = ServerRouter::new()
.endpoint(|| TestEndpoint::<7>)
.endpoint(|| TestEndpoint::<27>);
router.compile_routes();
let result = router.match_own_routes("/users", &Method::GET);
assert!(result.is_some());
let result = router.match_own_routes("/users", &Method::POST);
assert!(result.is_some());
let result = router.match_own_routes("/users", &Method::DELETE);
assert!(result.is_none());
}
#[rstest]
fn test_validate_routes_success() {
let router = ServerRouter::new()
.endpoint(|| TestEndpoint::<3>)
.endpoint(|| TestEndpoint::<8>);
let result = router.validate_routes();
assert!(result.is_ok());
}
#[rstest]
fn test_compile_routes_returns_errors_for_duplicate_routes() {
let router = ServerRouter::new()
.endpoint(|| TestEndpoint::<7>)
.endpoint(|| TestEndpoint::<25>);
let errors = router.compile_routes();
assert!(!errors.is_empty());
assert!(errors[0].contains("Failed to compile route"));
}
#[rstest]
fn test_validate_routes_returns_errors_for_invalid_patterns() {
let router = ServerRouter::new()
.endpoint(|| TestEndpoint::<9>)
.endpoint(|| TestEndpoint::<26>);
let result = router.validate_routes();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(!errors.is_empty());
}
#[rstest]
fn test_router_recovers_from_poisoned_rwlock() {
let router = ServerRouter::new().endpoint(|| TestEndpoint::<1>);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = router.routes_compiled.write().unwrap();
panic!("intentional panic to poison lock");
}));
let errors = router.compile_routes();
assert!(errors.is_empty());
let result = router.match_own_routes("/health", &Method::GET);
assert!(result.is_some());
}
#[rstest]
fn test_route_matching_recovers_from_poisoned_method_router() {
let router = ServerRouter::new().endpoint(|| TestEndpoint::<1>);
router.compile_routes();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = router.get_router.write().unwrap();
panic!("intentional panic to poison lock");
}));
let result = router.match_own_routes("/health", &Method::GET);
assert!(result.is_some());
}
struct NoopMiddleware;
#[async_trait::async_trait]
impl Middleware for NoopMiddleware {
async fn process(
&self,
request: reinhardt_http::Request,
next: std::sync::Arc<dyn reinhardt_http::Handler>,
) -> reinhardt_http::Result<reinhardt_http::Response> {
next.handle(request).await
}
}
fn create_test_request(path: &str) -> reinhardt_http::Request {
reinhardt_http::Request::builder()
.method(Method::GET)
.uri(path)
.version(hyper::Version::HTTP_11)
.headers(hyper::HeaderMap::new())
.body(bytes::Bytes::new())
.build()
.unwrap()
}
#[rstest]
fn test_server_router_exclude_stores_exclusion() {
let router = ServerRouter::new()
.with_middleware(NoopMiddleware)
.exclude("/api/auth/")
.exclude("/health");
assert_eq!(router.middleware_exclusions.len(), 1);
assert_eq!(router.middleware_exclusions[0].len(), 2);
assert_eq!(router.middleware_exclusions[0][0], "/api/auth/");
assert_eq!(router.middleware_exclusions[0][1], "/health");
}
#[rstest]
fn test_server_router_exclude_only_affects_last_middleware() {
let router = ServerRouter::new()
.with_middleware(NoopMiddleware)
.exclude("/admin/")
.with_middleware(NoopMiddleware)
.exclude("/api/auth/");
assert_eq!(router.middleware_exclusions.len(), 2);
assert_eq!(router.middleware_exclusions[0], vec!["/admin/"]);
assert_eq!(router.middleware_exclusions[1], vec!["/api/auth/"]);
}
#[rstest]
#[should_panic(expected = "exclude() called with no middleware")]
fn test_server_router_exclude_panics_without_middleware() {
let _router = ServerRouter::new().exclude("/api/auth/");
}
#[rstest]
fn test_server_router_build_middleware_with_exclusions() {
let router = ServerRouter::new()
.with_middleware(NoopMiddleware)
.exclude("/admin/")
.with_middleware(NoopMiddleware);
let built = router.build_middleware_with_exclusions();
assert_eq!(built.len(), 2);
let request_admin = create_test_request("/admin/dashboard");
let request_public = create_test_request("/public");
assert!(!built[0].should_continue(&request_admin));
assert!(built[0].should_continue(&request_public));
assert!(built[1].should_continue(&request_admin));
assert!(built[1].should_continue(&request_public));
}
struct SecurityHeaderTestMiddleware;
#[async_trait::async_trait]
impl Middleware for SecurityHeaderTestMiddleware {
async fn process(
&self,
request: reinhardt_http::Request,
next: std::sync::Arc<dyn reinhardt_http::Handler>,
) -> reinhardt_http::Result<reinhardt_http::Response> {
let mut response = next.handle(request).await?;
response.headers.insert(
hyper::header::HeaderName::from_static("x-security-test"),
hyper::header::HeaderValue::from_static("applied"),
);
Ok(response)
}
}
#[rstest]
#[tokio::test]
async fn test_404_response_gets_middleware_headers() {
let router = ServerRouter::new()
.with_middleware(SecurityHeaderTestMiddleware)
.endpoint(|| TestEndpoint::<10>);
let request = create_test_request("/nonexistent");
let response = Handler::handle(&router, request).await.unwrap();
assert_eq!(response.status, hyper::StatusCode::NOT_FOUND);
assert_eq!(
response
.headers
.get("x-security-test")
.map(|v| v.to_str().unwrap()),
Some("applied"),
"Framework-level 404 response should have middleware security header"
);
}
#[rstest]
#[tokio::test]
async fn test_405_response_gets_middleware_headers() {
let router = ServerRouter::new()
.with_middleware(SecurityHeaderTestMiddleware)
.endpoint(|| TestEndpoint::<10>);
let request = reinhardt_http::Request::builder()
.method(Method::POST)
.uri("/api/users/")
.version(hyper::Version::HTTP_11)
.headers(hyper::HeaderMap::new())
.body(bytes::Bytes::new())
.build()
.unwrap();
let response = Handler::handle(&router, request).await.unwrap();
assert_eq!(response.status, hyper::StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
response
.headers
.get("x-security-test")
.map(|v| v.to_str().unwrap()),
Some("applied"),
"Framework-level 405 response should have middleware security header"
);
}
#[rstest]
#[tokio::test]
async fn test_404_without_middleware_returns_error() {
let router = ServerRouter::new().endpoint(|| TestEndpoint::<10>);
let request = create_test_request("/nonexistent");
let result = Handler::handle(&router, request).await;
assert!(result.is_err(), "404 without middleware should return Err");
}
#[rstest]
#[tokio::test]
async fn test_404_respects_middleware_exclusions() {
let router = ServerRouter::new()
.with_middleware(SecurityHeaderTestMiddleware)
.exclude("/admin/")
.endpoint(|| TestEndpoint::<10>);
let request = create_test_request("/admin/nonexistent");
let response = Handler::handle(&router, request).await.unwrap();
assert_eq!(response.status, hyper::StatusCode::NOT_FOUND);
assert!(
response.headers.get("x-security-test").is_none(),
"404 under excluded path should NOT have middleware security header"
);
}
#[rstest]
#[tokio::test]
async fn test_endpoint_route_with_prefix_strips_prefix_during_compilation() {
let router = ServerRouter::new()
.with_prefix("/api")
.endpoint(|| TestEndpoint::<11>);
let result = router.resolve("/api/server_fn/test", &Method::POST);
assert!(
result.is_some(),
"POST /api/server_fn/test should match when router has prefix /api"
);
}
#[rstest]
#[tokio::test]
async fn test_endpoint_route_post_with_prefix_no_405() {
let router = ServerRouter::new()
.with_prefix("/api")
.endpoint(|| TestEndpoint::<12>);
let result = router.resolve("/api/users", &Method::POST);
assert!(
result.is_some(),
"POST /api/users should match when router has prefix /api (no 405)"
);
let get_result = router.resolve("/api/users", &Method::GET);
assert!(
get_result.is_none(),
"GET /api/users should not match a POST-only route"
);
}
#[rstest]
#[tokio::test]
async fn test_endpoint_route_without_prefix_overlap_still_works() {
let router = ServerRouter::new()
.with_prefix("/api")
.endpoint(|| TestEndpoint::<1>);
let result = router.resolve("/api/health", &Method::GET);
assert!(
result.is_some(),
"/api/health should match /health route under /api prefix"
);
}
#[rstest]
#[case("/api/", "/api/auth/register/", "/auth/register/")]
#[case("/api", "/api/auth/register/", "/auth/register/")]
#[case("/api/", "/api/", "/")]
#[case("/api", "/api", "/")]
#[case("/api/", "/api/health", "/health")]
#[case("/v1/", "/v1/users/", "/users/")]
#[case("", "/anything", "/anything")]
#[case("", "/", "/")]
#[case("", "/a/b/c", "/a/b/c")]
#[case("/", "/health", "/health")]
#[case("/", "/a/b/c", "/a/b/c")]
#[case("/api/v2/internal/", "/api/v2/internal/metrics", "/metrics")]
#[case("/api/", "/api/users%2F123/", "/users%2F123/")]
#[case("/api/", "/api/my-resource/sub_path/", "/my-resource/sub_path/")]
fn test_strip_prefix_normalized(#[case] prefix: &str, #[case] path: &str, #[case] expected: &str) {
let result = ServerRouter::strip_prefix_normalized(prefix, path);
assert!(
result.is_some(),
"strip_prefix_normalized({prefix:?}, {path:?}) should return Some"
);
let normalized = result.unwrap();
assert_eq!(
normalized.as_ref(),
expected,
"strip_prefix_normalized({prefix:?}, {path:?})"
);
}
#[rstest]
#[case("/api/", "/web/page")]
#[case("/api", "/web/page")]
#[case("/api/", "/ap")]
#[case("/api", "/ap")]
#[case("/api/", "")]
#[case("/", "")]
#[case("/api/v2/", "/api/")]
fn test_strip_prefix_normalized_returns_none(#[case] prefix: &str, #[case] path: &str) {
let result = ServerRouter::strip_prefix_normalized(prefix, path);
assert!(
result.is_none(),
"strip_prefix_normalized({prefix:?}, {path:?}) should return None"
);
}
#[rstest]
fn test_strip_prefix_normalized_result_always_starts_with_slash() {
let cases = [
("/api/", "/api/x"),
("/a/b/c/", "/a/b/c/d"),
("/", "/x"),
("", "/x"),
("/prefix/", "/prefix/rest/of/path"),
];
for (prefix, path) in cases {
let result = ServerRouter::strip_prefix_normalized(prefix, path);
let normalized = result.unwrap();
assert!(
normalized.starts_with('/'),
"result for ({prefix:?}, {path:?}) should start with '/' but got {normalized:?}"
);
}
}
#[rstest]
#[tokio::test]
async fn test_resolve_trailing_slash_prefix_child_router_matches() {
let child = ServerRouter::new()
.with_prefix("/auth/")
.endpoint(|| TestEndpoint::<13>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/auth/", child);
let result = parent.resolve("/api/auth/register/", &Method::POST);
assert!(
result.is_some(),
"POST /api/auth/register/ should match child route through trailing-slash prefix"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_no_trailing_slash_with_prefix_child_router_matches() {
let child = ServerRouter::new()
.with_prefix("/auth/")
.endpoint(|| TestEndpoint::<14>);
let parent = ServerRouter::new()
.with_prefix("/api")
.mount("/auth/", child);
let result = parent.resolve("/api/auth/login/", &Method::POST);
assert!(
result.is_some(),
"POST /api/auth/login/ should match child route with non-trailing-slash parent prefix"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_multiple_children_with_trailing_slash_prefix() {
let auth = ServerRouter::new()
.with_prefix("/auth/")
.endpoint(|| TestEndpoint::<14>);
let users = ServerRouter::new()
.with_prefix("/users/")
.endpoint(|| TestEndpoint::<15>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/auth/", auth)
.mount("/users/", users);
assert!(
parent.resolve("/api/auth/login/", &Method::POST).is_some(),
"POST /api/auth/login/ should match auth child"
);
assert!(
parent.resolve("/api/users/", &Method::GET).is_some(),
"GET /api/users/ should match users child"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_child_root_route_with_trailing_slash_prefix() {
let child = ServerRouter::new()
.with_prefix("/dashboard/")
.endpoint(|| TestEndpoint::<16>);
let parent = ServerRouter::new()
.with_prefix("/app/")
.mount("/dashboard/", child);
let result = parent.resolve("/app/dashboard/", &Method::GET);
assert!(
result.is_some(),
"GET /app/dashboard/ should match child root route"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_parent_own_route_still_works_with_trailing_slash_prefix() {
let child = ServerRouter::new()
.with_prefix("/sub/")
.endpoint(|| TestEndpoint::<18>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.endpoint(|| TestEndpoint::<17>)
.mount("/sub/", child);
assert!(
parent.resolve("/api/health", &Method::GET).is_some(),
"Parent's own route should still work"
);
assert!(
parent.resolve("/api/sub/action/", &Method::POST).is_some(),
"Child route should also work"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_deeply_nested_trailing_slash_prefixes() {
let grandchild = ServerRouter::new()
.with_prefix("/profile/")
.endpoint(|| TestEndpoint::<19>);
let child = ServerRouter::new()
.with_prefix("/users/")
.mount("/profile/", grandchild);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/users/", child);
let result = parent.resolve("/api/users/profile/", &Method::GET);
assert!(
result.is_some(),
"GET /api/users/profile/ should match through 3 levels of trailing-slash prefix stripping"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_mixed_trailing_and_non_trailing_slash_nesting() {
let grandchild = ServerRouter::new()
.with_prefix("/detail")
.endpoint(|| TestEndpoint::<20>);
let child = ServerRouter::new()
.with_prefix("/items/")
.mount("/detail/", grandchild);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/items/", child);
let result = parent.resolve("/api/items/detail/", &Method::GET);
assert!(
result.is_some(),
"Mixed trailing/non-trailing prefix nesting should resolve correctly"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_path_not_matching_parent_prefix() {
let child = ServerRouter::new()
.with_prefix("/auth/")
.endpoint(|| TestEndpoint::<14>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/auth/", child);
let result = parent.resolve("/web/auth/login/", &Method::POST);
assert!(
result.is_none(),
"Path not matching parent prefix should return None"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_path_matches_parent_but_not_child() {
let child = ServerRouter::new()
.with_prefix("/auth/")
.endpoint(|| TestEndpoint::<14>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/auth/", child);
let result = parent.resolve("/api/unknown/path/", &Method::GET);
assert!(
result.is_none(),
"Path matching parent but not child should return None"
);
}
#[rstest]
#[tokio::test]
async fn test_resolve_wrong_method_through_child_with_trailing_slash_prefix() {
let child = ServerRouter::new()
.with_prefix("/auth/")
.endpoint(|| TestEndpoint::<14>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/auth/", child);
let result = parent.resolve("/api/auth/login/", &Method::GET);
assert!(
result.is_none(),
"Wrong HTTP method through child router should return None"
);
}
#[rstest]
#[tokio::test]
async fn test_path_exists_with_trailing_slash_prefix_and_child() {
let child = ServerRouter::new()
.with_prefix("/users/")
.endpoint(|| TestEndpoint::<15>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/users/", child);
let exists = parent.path_exists_for_any_method("/api/users/");
assert!(
exists,
"path_exists_for_any_method should find path in child router after prefix normalization"
);
}
#[rstest]
#[tokio::test]
async fn test_path_exists_nonexistent_path_with_trailing_slash_prefix() {
let child = ServerRouter::new()
.with_prefix("/users/")
.endpoint(|| TestEndpoint::<15>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/users/", child);
let exists = parent.path_exists_for_any_method("/api/nonexistent/");
assert!(
!exists,
"path_exists_for_any_method should return false for nonexistent path"
);
}
#[rstest]
#[tokio::test]
async fn test_path_exists_wrong_prefix_returns_false() {
let child = ServerRouter::new()
.with_prefix("/users/")
.endpoint(|| TestEndpoint::<15>);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/users/", child);
let exists = parent.path_exists_for_any_method("/web/users/");
assert!(
!exists,
"path_exists_for_any_method with wrong parent prefix should return false"
);
}
#[rstest]
#[tokio::test]
async fn test_path_exists_deeply_nested_with_trailing_slash_prefix() {
let grandchild = ServerRouter::new()
.with_prefix("/edit/")
.endpoint(|| TestEndpoint::<21>);
let child = ServerRouter::new()
.with_prefix("/items/")
.mount("/edit/", grandchild);
let parent = ServerRouter::new()
.with_prefix("/api/")
.mount("/items/", child);
let exists = parent.path_exists_for_any_method("/api/items/edit/");
assert!(
exists,
"path_exists_for_any_method should find deeply nested path through trailing-slash prefixes"
);
}
#[rstest]
#[tokio::test]
async fn test_endpoint_route_with_trailing_slash_prefix_compiles_correctly() {
let router = ServerRouter::new()
.with_prefix("/api/")
.endpoint(|| TestEndpoint::<11>);
let result = router.resolve("/api/server_fn/test", &Method::POST);
assert!(
result.is_some(),
"Route with trailing-slash prefix should compile and resolve correctly"
);
}
#[rstest]
#[should_panic(expected = "URL route prefix cannot be an empty string")]
fn test_mount_with_empty_prefix_panics() {
let child = ServerRouter::new().endpoint(|| TestEndpoint::<22>);
let _parent = ServerRouter::new().with_prefix("/api/").mount("", child);
}
#[rstest]
#[tokio::test]
async fn test_resolve_child_with_slash_prefix_under_trailing_slash_parent() {
let child = ServerRouter::new()
.with_prefix("/")
.endpoint(|| TestEndpoint::<22>);
let parent = ServerRouter::new().with_prefix("/api/").mount("/", child);
let result = parent.resolve("/api/catch/", &Method::GET);
assert!(
result.is_some(),
"Child with '/' prefix under trailing-slash parent should match"
);
}
#[rstest]
fn test_register_all_routes_detects_duplicate_names() {
let mut router = ServerRouter::new()
.with_namespace("api")
.endpoint(|| TestEndpoint::<23>)
.endpoint(|| TestEndpoint::<24>);
let errors = router.register_all_routes();
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Duplicate route name 'api:list'"));
}
#[rstest]
fn test_validate_route_names_succeeds_with_unique_names() {
let router = ServerRouter::new()
.with_namespace("api")
.endpoint(|| TestEndpoint::<25>)
.endpoint(|| TestEndpoint::<26>);
let result = router.validate_route_names();
assert!(result.is_ok());
}
#[rstest]
fn test_validate_routes_includes_name_errors() {
let router = ServerRouter::new()
.with_namespace("api")
.endpoint(|| TestEndpoint::<23>)
.endpoint(|| TestEndpoint::<24>);
let result = router.validate_routes();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.contains("Duplicate route name")));
}