477 lines
16 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|