Files

477 lines
16 KiB
Rust

use std::collections::BTreeMap;
use wip_interface::WipType;
use wip_site_worldspace::wip_protocol::{
CallOperationRequest, FetchInterfaceRequest, InterfaceReference, InterfaceTarget,
ObserveRequest, ProtocolErrorCode, Target, Value,
};
use wip_site_worldspace::{
DocumentContent, DocumentNode, MAX_DEPTH, MAX_NODES, Worldspace, WorldspaceData,
};
fn node(path: &str, parent: Option<&str>, display: &str, href: &str) -> DocumentNode {
DocumentNode {
path: path.into(),
name: if path == "/" {
""
} else {
path.rsplit('/').next().unwrap()
}
.into(),
display_path: display.into(),
parent: parent.map(str::to_owned),
title: format!("Title {display}"),
href: href.into(),
html: format!("<h1>{display}</h1>"),
}
}
fn data() -> WorldspaceData {
WorldspaceData {
nodes: vec![
node("/", None, "/", "/"),
node("/book", Some("/"), "/book", "/book/"),
node(
"/book/1-chapter.md",
Some("/book"),
"/1-chapter.md",
"/book/1-chapter/",
),
node(
"/book/1-chapter.md/1.1-section.md",
Some("/book/1-chapter.md"),
"/1.1-section.md",
"/book/1.1-section/",
),
// This intentional order differs from lexical order.
node(
"/book/10-last.md",
Some("/book"),
"/10-last.md",
"/book/10-last/",
),
node(
"/book/2-second.md",
Some("/book"),
"/2-second.md",
"/book/2-second/",
),
node("/sources", Some("/"), "/sources", "/sources/"),
node("/packages", Some("/"), "/packages", "/packages/"),
],
}
}
fn read(path: &str) -> CallOperationRequest {
CallOperationRequest {
target: Target {
path: path.into(),
validator: None,
},
interface: InterfaceTarget {
reference: Worldspace::document_interface(),
scope_ref: None,
validator: None,
},
operation: "read".into(),
arguments: BTreeMap::new(),
}
}
#[test]
fn all_three_interactions_use_the_published_document_interface() {
let world = Worldspace::new(data()).unwrap();
let request = ObserveRequest {
path: "/book/1-chapter.md".into(),
depth: 1,
};
let observation = world.observe(request.clone()).unwrap();
request.validate_response(&observation).unwrap();
assert_eq!(
observation.object.interfaces,
vec![Worldspace::document_interface()]
);
assert_eq!(
observation.children.unwrap()[0].object.name,
"1.1-section.md"
);
let fetch = FetchInterfaceRequest {
interface: Worldspace::document_interface(),
};
let interface = world.fetch_interface(fetch.clone()).unwrap();
interface.validate_for(&fetch).unwrap();
assert_eq!(interface.descriptor.operations.len(), 1);
assert_eq!(interface.descriptor.operations[0].name, "read");
assert!(interface.descriptor.operations[0].parameters.is_empty());
assert!(interface.scope_ref.is_none());
for expected in data().nodes {
let call = read(&expected.path);
let response = world.call_operation(call.clone()).unwrap();
response
.validate_for(&interface.descriptor, &call.operation)
.unwrap();
assert_eq!(
DocumentContent::decode(response.result).unwrap(),
DocumentContent {
path: expected.path,
title: expected.title,
href: expected.href,
html: expected.html,
}
);
}
}
#[test]
fn depth_boundaries_empty_children_and_order_are_protocol_observations() {
let world = Worldspace::new(data()).unwrap();
let observe = |path: &str, depth| {
world
.observe(ObserveRequest {
path: path.into(),
depth,
})
.unwrap()
};
assert!(observe("/", 0).children.is_none());
let root = observe("/", 1);
let children = root.children.unwrap();
assert_eq!(children.len(), 3);
assert_eq!(children[0].object.name, "book");
assert_eq!(children[1].object.name, "sources");
assert_eq!(children[2].object.name, "packages");
assert!(children.iter().all(|child| child.children.is_none()));
for path in ["/sources", "/packages"] {
assert!(observe(path, 0).children.is_none());
assert_eq!(observe(path, 1).children, Some(vec![]));
let leaf = observe(path, 1);
assert_eq!(
leaf.object.interfaces,
vec![Worldspace::document_interface()]
);
let content =
DocumentContent::decode(world.call_operation(read(path)).unwrap().result).unwrap();
assert_eq!(content.path, path);
assert_eq!(content.href, format!("{path}/"));
assert_eq!(content.html, format!("<h1>{path}</h1>"));
}
let root = observe("/", 3);
let book = &root.children.as_ref().unwrap()[0];
let chapters = book.children.as_ref().unwrap();
assert_eq!(
chapters
.iter()
.map(|node| node.object.name.as_str())
.collect::<Vec<_>>(),
vec!["1-chapter.md", "10-last.md", "2-second.md"]
);
assert!(chapters[0].children.as_ref().unwrap()[0].children.is_none());
assert_eq!(chapters[1].children, Some(vec![]));
assert_eq!(
observe("/book/1-chapter.md/1.1-section.md", 1).children,
Some(vec![])
);
assert!(observe("/", MAX_DEPTH).children.is_some());
assert_eq!(
world
.observe(ObserveRequest {
path: "/".into(),
depth: MAX_DEPTH + 1
})
.unwrap_err()
.code,
ProtocolErrorCode::ResourceLimitExceeded
);
}
#[test]
fn canonical_host_failures_are_not_replaced_by_direct_body_lookup() {
let world = Worldspace::new(data()).unwrap();
for path in ["relative", "/book/", "/book//x", "/book/../x", "/./book"] {
assert_eq!(
world
.observe(ObserveRequest {
path: path.into(),
depth: 0
})
.unwrap_err()
.code,
ProtocolErrorCode::InvalidRequest
);
assert_eq!(
world.call_operation(read(path)).unwrap_err().code,
ProtocolErrorCode::InvalidRequest
);
}
for path in [
"/missing",
"/1-chapter.md",
"/draft/private.md",
"/book/draft/private.md",
"/sources/repository",
"/packages/crate",
] {
assert_eq!(
world
.observe(ObserveRequest {
path: path.into(),
depth: 1
})
.unwrap_err()
.code,
ProtocolErrorCode::NotFound
);
assert_eq!(
world.call_operation(read(path)).unwrap_err().code,
ProtocolErrorCode::NotFound
);
}
for reference in [
InterfaceReference {
scope: "/".into(),
name: "missing".into(),
},
InterfaceReference {
scope: "/book".into(),
name: "document".into(),
},
InterfaceReference {
scope: "/draft".into(),
name: "document".into(),
},
] {
assert_eq!(
world
.fetch_interface(FetchInterfaceRequest {
interface: reference.clone()
})
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let mut call = read("/book");
call.interface.reference = reference;
assert_eq!(
world.call_operation(call).unwrap_err().code,
ProtocolErrorCode::InterfaceMismatch
);
}
assert_eq!(
world
.fetch_interface(FetchInterfaceRequest {
interface: InterfaceReference {
scope: "relative".into(),
name: "document".into()
},
})
.unwrap_err()
.code,
ProtocolErrorCode::InvalidRequest
);
let mut call = read("/book");
call.interface.scope_ref = Some("not-the-published-root".into());
assert_eq!(
world.call_operation(call).unwrap_err().code,
ProtocolErrorCode::InterfaceMismatch
);
let mut call = read("/book");
call.operation = "missing".into();
assert_eq!(
world.call_operation(call).unwrap_err().code,
ProtocolErrorCode::OperationNotFound
);
let mut call = read("/book");
call.arguments
.insert("extra".into(), Value::String("not accepted".into()));
assert_eq!(
world.call_operation(call).unwrap_err().code,
ProtocolErrorCode::InvalidArguments
);
let mut call = read("/book");
call.target.validator = Some(vec![1]);
assert_eq!(
world.call_operation(call).unwrap_err().code,
ProtocolErrorCode::ValidatorMismatch
);
let mut call = read("/book");
call.interface.validator = Some(vec![1]);
assert_eq!(
world.call_operation(call).unwrap_err().code,
ProtocolErrorCode::InterfaceValidatorMismatch
);
}
#[test]
fn rejects_duplicate_invalid_disconnected_and_private_input() {
let reject = |mutate: fn(&mut WorldspaceData)| {
let mut input = data();
mutate(&mut input);
assert!(Worldspace::new(input).is_err());
};
reject(|data| data.nodes.push(data.nodes[0].clone()));
reject(|data| data.nodes[2].display_path = "/book".into());
reject(|data| data.nodes[2].href = "/book/".into());
reject(|data| data.nodes[2].name = "a/b".into());
reject(|data| data.nodes[2].name = "..".into());
reject(|data| data.nodes[2].name.clear());
reject(|data| data.nodes[2].path = "/book//x".into());
reject(|data| data.nodes[2].path = "/elsewhere".into());
reject(|data| data.nodes[2].parent = None);
reject(|data| data.nodes[3].parent = Some("/missing".into()));
reject(|data| data.nodes[0].name = "root".into());
reject(|data| data.nodes[0].parent = Some("/".into()));
reject(|data| data.nodes[0].display_path = "/home".into());
reject(|data| {
data.nodes.remove(0);
});
reject(|data| data.nodes[2].display_path = "/draft/secret.md".into());
reject(|data| {
data.nodes
.push(node("/draft", Some("/"), "/private", "/private/"))
});
reject(|data| data.nodes[0].href = "//external.example/".into());
reject(|data| data.nodes[0].title.clear());
assert!(Worldspace::new(WorldspaceData { nodes: vec![] }).is_err());
assert!(
Worldspace::new(WorldspaceData {
nodes: vec![data().nodes[0].clone(); MAX_NODES + 1]
})
.is_err()
);
let mut wide = WorldspaceData {
nodes: vec![data().nodes[0].clone()],
};
for index in 1..MAX_NODES {
let path = format!("/node-{index}");
wide.nodes
.push(node(&path, Some("/"), &path, &format!("{path}/")));
}
let world = Worldspace::new(wide).unwrap();
assert_eq!(
world
.observe(ObserveRequest {
path: "/".into(),
depth: 1
})
.unwrap()
.children
.unwrap()
.len(),
MAX_NODES - 1
);
let mut deep = WorldspaceData {
nodes: vec![data().nodes[0].clone()],
};
let mut parent = "/".to_string();
for _ in 0..=MAX_DEPTH {
let path = format!("{}/a", parent.trim_end_matches('/'));
deep.nodes
.push(node(&path, Some(&parent), &path, &format!("{path}/")));
parent = path;
if deep.nodes.len() == MAX_DEPTH as usize + 1 {
assert!(Worldspace::new(deep.clone()).is_ok());
}
}
assert!(Worldspace::new(deep).is_err());
}
#[test]
fn json_contract_roundtrips_and_rejects_malformed_or_unknown_fields() {
let json = serde_json::to_string(&data()).unwrap();
assert!(json.contains("displayPath"));
let world = Worldspace::from_json(&json).unwrap();
assert!(world.call_operation(read("/")).is_ok());
assert!(Worldspace::from_json("not json").is_err());
assert!(Worldspace::from_json(r#"{"nodes":[],"draft":true}"#).is_err());
let too_large = " ".repeat(wip_site_worldspace::MAX_JSON_BYTES + 1);
assert!(Worldspace::from_json(&too_large).is_err());
// Input order need not be topological; child order still follows the input.
let mut shuffled = data();
shuffled.nodes.reverse();
assert!(
Worldspace::new(shuffled)
.unwrap()
.observe(ObserveRequest {
path: "/".into(),
depth: 4
})
.is_ok()
);
}
/// This is deliberately explicit, not silently skipped when generated input is absent.
/// Run after `npm run build`: cargo test -p wip-site-worldspace --test worldspace -- --include-ignored
#[test]
#[ignore = "requires the site's generated dist/worldspace.json (npm run build)"]
fn generated_real_public_documents_support_all_interactions() {
let json = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../dist/worldspace.json"
))
.unwrap();
let data: WorldspaceData = serde_json::from_str(&json).unwrap();
assert!(data.nodes.len() > 10);
let world = Worldspace::from_json(&json).unwrap();
let full = world
.observe(ObserveRequest {
path: "/".into(),
depth: MAX_DEPTH,
})
.unwrap();
assert_eq!(
full.children
.unwrap()
.iter()
.map(|node| node.object.name.as_str())
.collect::<Vec<_>>(),
vec!["book", "sources", "packages", "playground"]
);
for path in ["/sources", "/packages"] {
assert_eq!(
world
.observe(ObserveRequest {
path: path.into(),
depth: 1,
})
.unwrap()
.children,
Some(vec![])
);
}
let descriptor = world
.fetch_interface(FetchInterfaceRequest {
interface: Worldspace::document_interface(),
})
.unwrap()
.descriptor;
for node in &data.nodes {
assert!(!node.display_path.starts_with("/draft/"));
assert!(!node.html.is_empty());
let request = read(&node.path);
let response = world.call_operation(request.clone()).unwrap();
response
.validate_for(&descriptor, &request.operation)
.unwrap();
let content = DocumentContent::decode(response.result).unwrap();
assert_eq!(content.path, node.path);
assert_eq!(content.href, node.href);
assert_eq!(content.html, node.html);
if let Some(parent) = &node.parent {
assert_eq!(
node.path,
format!("{}/{}", parent.trim_end_matches('/'), node.name)
);
}
}
assert!(data.nodes.iter().any(|node| node.path
== "/book/1-concept.md/1.1-interaction-abstraction.md"
&& node.display_path == "/1.1-interaction-abstraction.md"
&& node.href.ends_with("/book/1.1-interaction-abstraction/")));
for path in ["/draft", "/book/draft", "/book/draft/6-security.md"] {
assert_eq!(
world.call_operation(read(path)).unwrap_err().code,
ProtocolErrorCode::NotFound
);
}
}