#![cfg(all(feature = "docs", feature = "macros", feature = "json"))]
use kynos::{Router, http::StatusCode, openapi::Info, router::docs::Docs};
#[path = "support/mod.rs"]
mod support;
use support::{App, get, post};
fn served(docs: Docs) -> kynos::router::service::Service<App> {
support::router()
.info(Info::new("Example API", "1.0.0"))
.docs(docs)
.build(App::new())
.expect("a describable router")
}
#[test]
fn both_docs_routes_are_described_where_they_are_mounted() {
let document = support::router()
.docs(Docs::scalar())
.openapi()
.expect("a describable router");
for path in ["/docs", "/openapi.json"] {
let item = document
.paths
.items
.get(path)
.unwrap_or_else(|| panic!("{path} is missing from the document"));
assert!(item.get.is_some(), "{path} declares no GET");
}
}
#[test]
fn a_router_that_mounts_no_docs_describes_neither_path() {
let document = support::router().openapi().expect("a describable router");
for path in ["/docs", "/openapi.json"] {
assert!(
!document.paths.items.contains_key(path),
"{path} is described by a router that mounts no reference",
);
}
}
#[test]
fn the_page_is_described_as_html_and_the_description_as_json() {
let document = support::router()
.docs(Docs::scalar())
.openapi()
.expect("a describable router");
for (path, media_type) in [
("/docs", "text/html; charset=utf-8"),
("/openapi.json", "application/json"),
] {
let operation = document.paths.items[path].get.as_ref().expect("a GET");
let ok = operation
.responses
.responses
.get("200")
.and_then(kynos::openapi::RefOr::as_item)
.expect("a described 200");
assert!(
ok.content.contains_key(media_type),
"{path} does not declare {media_type}, only {:?}",
ok.content.keys().collect::<Vec<_>>(),
);
}
}
#[tokio::test]
async fn every_shipped_reference_is_served_as_html() {
for (docs, marker) in [
(Docs::scalar(), "Scalar.createApiReference"),
(Docs::redoc(), "Redoc.init"),
] {
let service = served(docs);
let reply = get(&service, "/docs").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert_eq!(
reply.field("content-type").as_deref(),
Some("text/html; charset=utf-8"),
);
assert!(
reply.text().contains(marker),
"the page does not boot the renderer that built it",
);
}
}
#[tokio::test]
async fn the_description_route_serves_the_document_this_router_emits() {
let router = support::router()
.info(Info::new("Example API", "1.0.0"))
.docs(Docs::scalar());
let emitted = router.openapi().expect("a describable router");
let expected = emitted.to_json().expect("a serializable document");
let service = router.build(App::new()).expect("a describable router");
let reply = get(&service, "/openapi.json").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert_eq!(
reply.field("content-type").as_deref(),
Some("application/json"),
);
assert_eq!(reply.text(), expected);
}
#[tokio::test]
async fn the_configured_paths_move_the_routes_and_the_pointer_together() {
let service = served(
Docs::scalar()
.at("/reference")
.description_at("/v1/openapi.json"),
);
let page = get(&service, "/reference").call().await;
assert_eq!(page.status, StatusCode::OK);
assert_eq!(
get(&service, "/v1/openapi.json").call().await.status,
StatusCode::OK,
);
assert!(
page.text().contains("/v1/openapi.json"),
"the page still fetches somewhere else",
);
for stale in ["/docs", "/openapi.json"] {
assert_eq!(
get(&service, stale).call().await.status,
StatusCode::NOT_FOUND,
"{stale} is still served",
);
}
}
#[tokio::test]
async fn nesting_moves_both_routes_and_the_page_follows_them() {
let service = Router::<App>::new()
.info(Info::new("Example API", "1.0.0"))
.nest("/api", support::router().docs(Docs::scalar()))
.build(App::new())
.expect("a describable router");
let page = get(&service, "/api/docs").call().await;
assert_eq!(page.status, StatusCode::OK);
assert_eq!(
get(&service, "/api/openapi.json").call().await.status,
StatusCode::OK,
);
assert_eq!(
get(&service, "/docs").call().await.status,
StatusCode::NOT_FOUND,
);
assert!(
page.text().contains("/api/openapi.json"),
"the nested page fetches the unprefixed path",
);
let described = get(&service, "/api/openapi.json").call().await.json();
let paths = described["paths"].as_object().expect("a paths object");
assert!(paths.contains_key("/api/docs"));
assert!(paths.contains_key("/api/openapi.json"));
}
#[tokio::test]
async fn the_title_defaults_to_the_document_title() {
let service = served(Docs::scalar());
assert!(
get(&service, "/docs")
.call()
.await
.text()
.contains("Example API"),
"the page does not carry the document's own title",
);
}
#[tokio::test]
async fn a_configured_title_wins_over_the_document() {
let service = served(Docs::scalar().title("Widgets"));
let page = get(&service, "/docs").call().await.text();
assert!(
page.contains("Widgets"),
"the configured title did not reach the page",
);
assert!(
!page.contains("Example API"),
"the document's own title is on the page beside the configured one",
);
}
#[tokio::test]
async fn either_docs_route_answers_only_get() {
let service = served(Docs::scalar());
for path in ["/docs", "/openapi.json"] {
let reply = post(&service, path).call().await;
assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED, "{path}");
assert_eq!(reply.field("allow").as_deref(), Some("GET"), "{path}");
}
}
#[test]
fn a_docs_path_that_is_not_a_template_is_reported_rather_than_panicking() {
let router = support::router().docs(Docs::scalar().at("docs"));
let violations = router.validate().expect("validation itself succeeds");
assert!(
violations.iter().any(|violation| matches!(
&violation.error,
kynos::openapi::SpecError::InvalidPathTemplate { template, .. } if template == "docs"
)),
"expected the bad path among the violations, got {violations:?}"
);
assert!(router.build(App::new()).is_err());
}