#![cfg(all(feature = "macros", feature = "json"))]
use kynos::{
Router,
extract::{
body::text::Text,
connection::{ConnectInfo, MatchedPath},
params::path::Path,
},
http::{Method, StatusCode, header},
response::status::NoContent,
router::policy::{FallbackPolicy, TrailingSlashPolicy},
};
#[path = "support/mod.rs"]
mod support;
use support::{App, get, send, service};
#[tokio::test]
async fn a_matched_request_reaches_its_operation() {
let reply = get(&service(), "/users/42").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert_eq!(reply.json()["id"], 42);
assert_eq!(reply.json()["name"], "user from pool 7");
}
#[tokio::test]
async fn a_path_no_template_matches_is_not_found() {
let reply = get(&service(), "/widgets").call().await;
assert_eq!(reply.status, StatusCode::NOT_FOUND);
assert_eq!(
reply.field(header::CONTENT_TYPE.as_str()).as_deref(),
Some("application/problem+json")
);
assert_eq!(reply.json()["status"], 404);
}
#[tokio::test]
async fn a_method_no_operation_declares_is_refused_with_what_is_allowed() {
let reply = send(&service(), Method::PATCH, "/users/42").call().await;
assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED);
let allow = reply.field(header::ALLOW.as_str()).expect("an Allow field");
let mut methods: Vec<&str> = allow.split(", ").collect();
methods.sort_unstable();
assert_eq!(methods, ["DELETE", "GET"]);
}
#[tokio::test]
async fn a_trailing_slash_variant_is_redirected_when_the_policy_says_so() {
let service = support::router()
.trailing_slashes(TrailingSlashPolicy::Redirect)
.build(App::new())
.expect("a describable router");
let reply = get(&service, "/users/42/").call().await;
assert_eq!(reply.status, StatusCode::PERMANENT_REDIRECT);
assert_eq!(
reply.field(header::LOCATION.as_str()).as_deref(),
Some("/users/42")
);
}
#[tokio::test]
async fn a_trailing_slash_variant_is_a_plain_miss_by_default() {
let reply = get(&service(), "/users/42/").call().await;
assert_eq!(reply.status, StatusCode::NOT_FOUND);
assert!(reply.field(header::LOCATION.as_str()).is_none());
}
#[tokio::test]
async fn a_redirect_carries_the_query_it_was_given() {
let service = support::router()
.trailing_slashes(TrailingSlashPolicy::Redirect)
.build(App::new())
.expect("a describable router");
let reply = get(&service, "/users/?limit=2").call().await;
assert_eq!(reply.status, StatusCode::PERMANENT_REDIRECT);
assert_eq!(
reply.field(header::LOCATION.as_str()).as_deref(),
Some("/users?limit=2")
);
}
#[kynos::get("/things/")]
async fn list_things() -> Text {
Text("things".to_owned())
}
#[kynos::get("/pair")]
async fn pair_bare() -> Text {
Text("bare".to_owned())
}
#[kynos::get("/pair/")]
async fn pair_slashed() -> Text {
Text("slashed".to_owned())
}
#[kynos::get("/label")]
async fn echo_label(MatchedPath(template): MatchedPath) -> Text {
Text(template.to_owned())
}
#[tokio::test]
async fn both_spellings_reach_the_operation_under_lenient() {
let service = support::router()
.trailing_slashes(TrailingSlashPolicy::Lenient)
.build(App::new())
.expect("a describable router");
let declared = get(&service, "/users/42").call().await;
let flipped = get(&service, "/users/42/").call().await;
assert_eq!(declared.status, StatusCode::OK);
assert_eq!(flipped.status, StatusCode::OK);
assert!(flipped.field(header::LOCATION.as_str()).is_none());
assert_eq!(flipped.json()["id"], 42);
assert_eq!(declared.json(), flipped.json());
}
#[tokio::test]
async fn lenient_adds_no_paths_key() {
let strict = support::router().openapi().expect("a describable router");
let lenient = support::router()
.trailing_slashes(TrailingSlashPolicy::Lenient)
.openapi()
.expect("a describable router");
let keys: Vec<&String> = lenient.paths.items.keys().collect();
assert_eq!(
keys,
strict.paths.items.keys().collect::<Vec<_>>(),
"a policy that only chooses what to match changed what is described"
);
assert!(!keys.iter().any(|key| key.ends_with('/')), "{keys:?}");
}
#[tokio::test]
async fn a_route_declared_with_a_slash_is_reachable_without_one() {
let service = Router::<()>::new()
.mount(kynos::routes![list_things])
.trailing_slashes(TrailingSlashPolicy::Lenient)
.build(())
.expect("a describable router");
assert_eq!(get(&service, "/things/").call().await.text(), "things");
assert_eq!(get(&service, "/things").call().await.text(), "things");
}
#[tokio::test]
async fn a_route_declared_with_a_slash_is_a_plain_miss_by_default() {
let service = Router::<()>::new()
.mount(kynos::routes![list_things])
.build(())
.expect("a describable router");
assert_eq!(get(&service, "/things/").call().await.text(), "things");
assert_eq!(
get(&service, "/things").call().await.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn a_declared_spelling_is_never_displaced_by_a_flipped_one() {
let service = Router::<()>::new()
.mount(kynos::routes![pair_bare, pair_slashed])
.trailing_slashes(TrailingSlashPolicy::Lenient)
.build(())
.expect("a describable router");
assert_eq!(get(&service, "/pair").call().await.text(), "bare");
assert_eq!(get(&service, "/pair/").call().await.text(), "slashed");
}
#[tokio::test]
async fn a_flipped_spelling_reports_the_declared_template() {
let service = Router::<()>::new()
.mount(kynos::routes![echo_label])
.trailing_slashes(TrailingSlashPolicy::Lenient)
.build(())
.expect("a describable router");
assert_eq!(get(&service, "/label").call().await.text(), "/label");
assert_eq!(get(&service, "/label/").call().await.text(), "/label");
}
#[tokio::test]
async fn a_flipped_spelling_still_refuses_a_method_no_operation_declares() {
let service = support::router()
.trailing_slashes(TrailingSlashPolicy::Lenient)
.build(App::new())
.expect("a describable router");
let reply = send(&service, Method::PATCH, "/users/42/").call().await;
assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED);
let allow = reply.field(header::ALLOW.as_str()).expect("an Allow field");
let mut methods: Vec<&str> = allow.split(", ").collect();
methods.sort_unstable();
assert_eq!(methods, ["DELETE", "GET"]);
}
#[tokio::test]
async fn an_empty_fallback_sends_the_status_and_nothing_else() {
let service = support::router()
.not_found(FallbackPolicy::Empty)
.method_not_allowed(FallbackPolicy::Empty)
.build(App::new())
.expect("a describable router");
let missing = get(&service, "/widgets").call().await;
assert_eq!(missing.status, StatusCode::NOT_FOUND);
assert!(missing.body.is_empty(), "{:?}", missing.text());
let refused = send(&service, Method::PATCH, "/users/42").call().await;
assert_eq!(refused.status, StatusCode::METHOD_NOT_ALLOWED);
assert!(refused.body.is_empty(), "{:?}", refused.text());
assert!(refused.field(header::ALLOW.as_str()).is_some());
}
#[kynos::get("/echo/{id}")]
async fn echo_matched_path(
Path(_): Path<support::UserPath>,
MatchedPath(template): MatchedPath,
) -> Text {
Text(template.to_owned())
}
#[tokio::test]
async fn the_matched_path_is_the_template_and_not_the_request_target() {
let service = Router::<()>::new()
.mount(kynos::routes![echo_matched_path])
.build(())
.expect("a describable router");
let first = get(&service, "/echo/1").call().await;
let second = get(&service, "/echo/99999").call().await;
assert_eq!(first.text(), "/echo/{id}");
assert_eq!(
first.text(),
second.text(),
"two concrete paths under one template produced two labels"
);
}
#[kynos::get("/nothing")]
async fn nothing() -> NoContent {
NoContent
}
#[tokio::test]
async fn an_operation_returning_no_content_sends_no_body() {
let service = Router::<()>::new()
.mount(kynos::routes![nothing])
.build(())
.expect("a describable router");
let reply = get(&service, "/nothing").call().await;
assert_eq!(reply.status, StatusCode::NO_CONTENT);
assert!(reply.body.is_empty());
}
#[kynos::get("/who")]
async fn who(peer: ConnectInfo) -> Text {
Text(peer.0.to_string())
}
#[tokio::test]
async fn a_directly_driven_service_reports_an_in_process_connection() {
let service = Router::<()>::new()
.mount(kynos::routes![who])
.build(())
.expect("a describable router");
let reply = get(&service, "/who").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert!(
reply.text().ends_with(":0"),
"an in-process connection reported `{}`",
reply.text()
);
}