apsis-book/tests/api.rs

297 lines
9.3 KiB
Rust

//! Tests for the router and the payloads it encodes.
//!
//! They live here rather than inside the module so that `src/` holds no test
//! code, as it does in `apsis-rs`. An integration test is a separate crate and
//! reaches only what the library makes public, so the few internal items the
//! tests need are opened through `apsis_book::testkit`.
use std::sync::Arc;
use apsis_book::api::*;
use apsis_book::http::request::{Method, Request};
use apsis_book::http::response::{Response, Status};
use apsis_book::http::server::Handler;
use apsis_book::json::{self, Value};
use apsis_book::state::AppState;
use apsis_book::testkit::{
UNHASHED_CACHE_POLICY, cache_policy, crate_summary_brief, is_content_hashed,
};
use std::collections::HashMap;
fn router() -> Router {
Router::new(Arc::new(AppState::new()))
}
fn get(router: &Router, path: &str) -> Response {
// The request parser splits the target, so a test that wants a query
// supplies it in the path and it is split the same way here.
let (path, query) = match path.split_once('?') {
Some((path, query)) => (path.to_string(), Some(query.to_string())),
None => (path.to_string(), None),
};
let request = Request {
method: Method::Get,
path,
query,
headers: HashMap::new(),
body: Vec::new(),
};
router.handle(&request, "127.0.0.1:1".parse().unwrap())
}
fn post(router: &Router, path: &str, body: &str) -> Response {
let request = Request {
method: Method::Post,
path: path.to_string(),
query: None,
headers: HashMap::new(),
body: body.as_bytes().to_vec(),
};
router.handle(&request, "127.0.0.1:1".parse().unwrap())
}
fn parse(response: &Response) -> Value {
json::parse(std::str::from_utf8(&response.body).unwrap()).unwrap()
}
#[test]
fn health_reports_the_content_counts() {
let response = get(&router(), "/health");
assert_eq!(response.status, Status::Ok);
let body = parse(&response);
assert_eq!(body.get("status").unwrap().as_str(), Some("ok"));
assert!(body.get("documents").unwrap().as_f64().unwrap() > 0.0);
}
#[test]
fn document_routes_serve_content() {
let router = router();
let index = parse(&get(&router, "/api/docs"));
let groups = index.get("groups").unwrap().as_array();
assert_eq!(groups.len(), 2, "the concepts and the implementation");
let first = groups[0]
.get("sections")
.unwrap()
.as_array()
.first()
.unwrap()
.get("documents")
.unwrap()
.as_array()
.first()
.unwrap()
.get("id")
.unwrap()
.as_str()
.unwrap()
.to_string();
let document = parse(&get(&router, &format!("/api/docs/{first}")));
let forms = document.get("forms").unwrap().as_array();
assert!(!forms.is_empty());
assert!(
forms[0]
.get("html")
.unwrap()
.as_str()
.unwrap()
.contains('<')
);
}
#[test]
fn documents_carry_both_languages() {
let router = router();
let document = parse(&get(&router, "/api/docs/sgp4"));
let forms = document.get("forms").unwrap().as_array();
assert_eq!(forms.len(), 2, "the sample document has a translation");
let languages: Vec<&str> = forms
.iter()
.map(|form| form.get("language").unwrap().as_str().unwrap())
.collect();
assert!(languages.contains(&"zh"));
assert!(languages.contains(&"en"));
}
#[test]
fn document_listing_carries_bilingual_titles() {
let router = router();
let index = parse(&get(&router, "/api/docs"));
let groups = index.get("groups").unwrap().as_array();
let title = groups[0].get("title").unwrap();
assert!(title.get("zh").unwrap().as_str().is_some());
assert!(title.get("en").unwrap().as_str().is_some());
let section = groups[0]
.get("sections")
.unwrap()
.as_array()
.first()
.unwrap();
assert!(
section
.get("title")
.unwrap()
.get("zh")
.unwrap()
.as_str()
.is_some()
);
}
#[test]
fn search_returns_ranked_hits() {
let response = get(&router(), "/api/docs/search?q=propagation");
let body = parse(&response);
assert!(body.get("count").unwrap().as_f64().unwrap() > 0.0);
}
#[test]
fn graph_routes_serve_the_concept_and_crate_graphs() {
let router = router();
let concepts = parse(&get(&router, "/api/graph"));
assert!(!concepts.get("nodes").unwrap().as_array().is_empty());
let crates = parse(&get(&router, "/api/graph/crates"));
assert!(!crates.get("nodes").unwrap().as_array().is_empty());
}
#[test]
fn api_catalog_routes_serve_items() {
let router = router();
let index = parse(&get(&router, "/api/api"));
assert!(!index.get("crates").unwrap().as_array().is_empty());
let items = index.get("items").unwrap().as_array();
let name = items
.first()
.unwrap()
.get("name")
.unwrap()
.as_str()
.unwrap();
let item = parse(&get(&router, &format!("/api/api/items/{name}")));
assert!(item.get("signature").is_some());
}
#[test]
fn tool_registry_and_execution() {
let router = router();
let registry = parse(&get(&router, "/api/tools"));
assert!(registry.get("tools").unwrap().as_array().len() >= 7);
let response = post(&router, "/api/tools/sgp4-propagate/run", r#"{"minutes":0}"#);
let body = parse(&response);
assert_eq!(body.get("ok").unwrap().as_bool(), Some(true));
}
#[test]
fn tool_failures_are_reported_with_the_parameter() {
let router = router();
let response = post(
&router,
"/api/tools/sgp4-propagate/run",
r#"{"minutes":100000000}"#,
);
let body = parse(&response);
assert_eq!(body.get("ok").unwrap().as_bool(), Some(false));
assert_eq!(
body.get("error")
.unwrap()
.get("parameter")
.unwrap()
.as_str(),
Some("minutes")
);
}
#[test]
fn malformed_bodies_are_rejected() {
let router = router();
let response = post(&router, "/api/tools/sgp4-propagate/run", "{");
let body = parse(&response);
assert_eq!(body.get("ok").unwrap().as_bool(), Some(false));
}
#[test]
fn a_hashed_asset_is_immutable_and_a_plain_one_is_not() {
// A content hash in the name means the name changes with the
// contents, so the browser can keep it for as long as it likes.
assert_eq!(
cache_policy("assets/index-Vou83oaV.js"),
"public, max-age=31536000, immutable"
);
assert_eq!(
cache_policy("assets/style-CEH25aoZ.css"),
"public, max-age=31536000, immutable"
);
// A name without one is reused by the next build.
assert_eq!(cache_policy("index.html"), UNHASHED_CACHE_POLICY);
assert_eq!(cache_policy("assets/app.js"), UNHASHED_CACHE_POLICY);
assert_eq!(cache_policy("favicon.ico"), UNHASHED_CACHE_POLICY);
// A name that merely ends in a dash is not hashed.
assert_eq!(cache_policy("assets/app-.js"), UNHASHED_CACHE_POLICY);
assert_eq!(cache_policy("assets/no-extension"), UNHASHED_CACHE_POLICY);
}
#[test]
fn a_hash_is_recognised_by_its_shape() {
// The bundler emits a mixed case base64 hash.
assert!(is_content_hashed("index-Vou83oaV.js"));
assert!(is_content_hashed("style-CEH25aoZ.css"));
assert!(is_content_hashed("app-deadbeef.js"));
assert!(!is_content_hashed("app-abc.js"), "too few digits");
assert!(!is_content_hashed("app-na-me.js"), "a dash is not a hash");
assert!(!is_content_hashed("app.js"), "no hash segment");
assert!(!is_content_hashed("noextension"));
}
#[test]
fn unknown_routes_fall_back_to_the_entry_point() {
let router = router();
let response = get(&router, "/some/client/route");
assert_eq!(response.status, Status::Ok);
assert!(response.content_type.starts_with("text/html"));
}
#[test]
fn unknown_api_resources_report_not_found() {
let router = router();
let response = get(&router, "/api/docs/not-a-document");
assert_eq!(response.status, Status::NotFound);
let body = parse(&response);
assert_eq!(body.get("ok").unwrap().as_bool(), Some(false));
}
#[test]
fn post_to_a_get_route_is_refused() {
let router = router();
assert_eq!(
post(&router, "/api/docs", "{}").status,
Status::MethodNotAllowed
);
}
#[test]
fn a_crate_card_takes_the_first_sentence_only() {
// The index shows what a crate is; the crate page shows the rest.
assert_eq!(
crate_summary_brief(
"ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}\u{672c} crate \u{4f4d}\u{4e8e}\u{4f9d}\u{8d56}\u{56fe}\u{7684}\u{5e95}\u{5c42}\u{3002}"
),
"ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}"
);
}
#[test]
fn a_number_is_not_a_sentence_end() {
// A version or a measurement must not cut a sentence in half.
assert_eq!(
crate_summary_brief("Reads version 1.5 of the format. Then it checksums."),
"Reads version 1.5 of the format."
);
}
#[test]
fn a_summary_without_a_full_stop_is_capped() {
let long = "word ".repeat(80);
let brief = crate_summary_brief(&long);
assert!(brief.chars().count() <= 158, "the brief is too long");
assert!(brief.ends_with('\u{2026}'), "it should be marked as cut");
}