312 lines
10 KiB
Rust
312 lines
10 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`.
|
||
//!
|
||
//! 它们放在这里而不是模块内部,使 `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");
|
||
}
|