apsis-book/tests/api.rs

313 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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`.
//!
//!
//! 它们放在这里而不是模块内部,使 `src/` 不含测试代码,与 `apsis-rs` 一致。
//! 集成测试是独立的 crate,只能触达库公开的部分,因此测试所需的那少数内部条目
//! 经 `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.
// 打包器输出的哈希是大小写混合的 base64。
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.
// 索引说明一个 crate 是什么;其余内容由 crate 页面呈现。
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");
}