a8ca62b924
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
232 lines
9.6 KiB
Rust
232 lines
9.6 KiB
Rust
//! Integration tests for registration, listing and dispatch by id.
|
|
|
|
use notescraft_engine::{CommandError, CommandInfo, CommandResult, CommandSpec, Error, ParamKind, ParamSpec, Registry};
|
|
use serde_json::{Value, json};
|
|
|
|
/// Keys a sample body tolerates before refusing.
|
|
const MAX_KEYS: usize = 32;
|
|
|
|
/// Stand-in for the future `Session`: enough to observe that a body ran.
|
|
#[derive(Debug, Default)]
|
|
struct Ctx {
|
|
allow: bool,
|
|
ran: Vec<String>,
|
|
last: Option<Value>,
|
|
}
|
|
|
|
fn record(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
ctx.ran.push("record".to_owned());
|
|
ctx.last = Some(params.clone());
|
|
if params.as_object().is_some_and(|map| map.len() > MAX_KEYS) {
|
|
return Err(CommandError::new("too_many_keys", "record takes a few keys"));
|
|
}
|
|
Ok(params.clone())
|
|
}
|
|
|
|
fn only_path(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
if ctx.allow { Ok(params.clone()) } else { Err(CommandError::new("locked", "vault is locked")) }
|
|
}
|
|
|
|
fn boom(_ctx: &mut Ctx, _params: &Value) -> CommandResult {
|
|
Err(CommandError::new("boom", "deliberate failure"))
|
|
}
|
|
|
|
fn kaboom(_ctx: &mut Ctx, _params: &Value) -> CommandResult {
|
|
panic!("deliberate panic in a test command body");
|
|
}
|
|
|
|
fn spec(id: &str) -> CommandSpec<Ctx> {
|
|
CommandSpec::new(id, format!("label {id}"), format!("doc {id}"), |_| true, record)
|
|
}
|
|
|
|
fn registry_with(ids: &[&str]) -> Registry<Ctx> {
|
|
let mut reg = Registry::new();
|
|
for id in ids {
|
|
reg.register(spec(id)).expect("test registration");
|
|
}
|
|
reg
|
|
}
|
|
|
|
#[test]
|
|
fn valid_and_invalid_ids() {
|
|
let reg = registry_with(&["a", "note.open", "plugin.demo.do-thing_1", &format!("x{}", "y".repeat(127))]);
|
|
assert!(reg.get("note.open").is_some());
|
|
|
|
let mut reg = reg;
|
|
for bad in ["", "Upper", "with space", "trailing.", ".leading", "double..dot", "plugin/demo", "emoji😀", &"z".repeat(129)] {
|
|
assert!(matches!(reg.register(spec(bad)), Err(Error::InvalidId { .. })), "expected `{bad}` to be refused");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_ids_are_rejected() {
|
|
let mut reg = registry_with(&["note.open"]);
|
|
assert!(matches!(reg.register(spec("note.open")), Err(Error::Duplicate(id)) if id == "note.open"));
|
|
assert_eq!(reg.len(), 1);
|
|
assert!(reg.register(spec("note.new")).is_ok());
|
|
assert_eq!(reg.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_ids_are_forced_into_their_namespace() {
|
|
let mut reg = registry_with(&["note.open"]);
|
|
reg.register_plugin_command("demo", spec("refresh")).expect("plugin registration");
|
|
reg.register_plugin_command("demo", spec("plugin.demo.already_full")).expect("already-namespaced id");
|
|
assert!(reg.get("plugin.demo.refresh").is_some());
|
|
assert!(reg.get("plugin.demo.already_full").is_some());
|
|
|
|
assert!(matches!(reg.register_plugin_command("demo", spec("plugin.other.escape")), Err(Error::PrefixEscape { .. })));
|
|
assert!(matches!(reg.register_plugin_command("DeMo", spec("x")), Err(Error::InvalidPluginId(_))));
|
|
assert!(reg.get("plugin.demo.escape").is_none());
|
|
|
|
assert_eq!(reg.unregister_prefix("plugin.demo."), 2);
|
|
assert!(reg.get("plugin.demo.refresh").is_none());
|
|
assert!(reg.get("note.open").is_some());
|
|
assert_eq!(reg.unregister_prefix("plugin.demo."), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn list_is_sorted_and_serialises_with_the_documented_shape() {
|
|
let mut reg = Registry::new();
|
|
reg.register(
|
|
CommandSpec::new("note.new", "New note", "creates a note", |_| true, record)
|
|
.with_menu(vec!["File".to_owned(), "New note".to_owned()])
|
|
.with_shortcut("Mod-n")
|
|
.with_param(ParamSpec::required("path", ParamKind::String, "note path")),
|
|
)
|
|
.expect("register");
|
|
reg.register(CommandSpec::new("app.quit", "Quit", "quits", |_: &Ctx| false, record)).expect("register");
|
|
|
|
let ctx = Ctx::default();
|
|
let listed = reg.list(&ctx);
|
|
let ids: Vec<&str> = listed.iter().map(|info| info.id.as_str()).collect();
|
|
assert_eq!(ids, ["app.quit", "note.new"]);
|
|
|
|
let value = serde_json::to_value(&listed).expect("serialisable");
|
|
let first = value.get(0).expect("first entry");
|
|
assert_eq!(first.get("id"), Some(&json!("app.quit")));
|
|
assert_eq!(first.get("label"), Some(&json!("Quit")));
|
|
assert_eq!(first.get("enabled"), Some(&json!(false)));
|
|
assert_eq!(first.get("shortcut"), Some(&Value::Null));
|
|
assert_eq!(first.get("params"), Some(&json!([])));
|
|
|
|
let second = value.get(1).expect("second entry");
|
|
assert_eq!(second.get("menu_path"), Some(&json!(["File", "New note"])));
|
|
assert_eq!(second.get("shortcut"), Some(&json!("Mod-n")));
|
|
assert_eq!(second.get("params"), Some(&json!([{ "name": "path", "kind": "string", "required": true, "doc": "note path" }])));
|
|
|
|
let round_trip: Vec<CommandInfo> = serde_json::from_value(value).expect("deserialisable");
|
|
assert_eq!(round_trip, listed);
|
|
assert_eq!(listed.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn run_dispatches_by_id_and_reports_failures() {
|
|
let mut reg = Registry::new();
|
|
reg.register(CommandSpec::new("note.read", "Read", "reads", |_| true, only_path)).expect("register");
|
|
reg.register(CommandSpec::new("note.boom", "Boom", "fails", |_| true, boom)).expect("register");
|
|
let mut ctx = Ctx { allow: true, ..Ctx::default() };
|
|
|
|
assert_eq!(reg.run("note.read", &mut ctx, &Value::Null), Ok(Value::Null));
|
|
assert!(matches!(reg.run("note.boom", &mut ctx, &Value::Null), Err(Error::Command { .. })));
|
|
ctx.allow = false;
|
|
let err = reg.run("note.read", &mut ctx, &Value::Null).err();
|
|
assert!(matches!(
|
|
err,
|
|
Some(Error::Command { ref source, .. }) if source == &CommandError::new("locked", "vault is locked")
|
|
));
|
|
assert!(reg.get("note.read").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_disabled_and_null_params() {
|
|
let mut reg = Registry::new();
|
|
reg.register(CommandSpec::new("note.pick", "Pick", "needs allow", |ctx: &Ctx| ctx.allow, record)).expect("register");
|
|
let mut ctx = Ctx { allow: false, ..Ctx::default() };
|
|
|
|
assert!(matches!(reg.run("nope.nope", &mut ctx, &Value::Null), Err(Error::UnknownCommand(id)) if id == "nope.nope"));
|
|
assert!(matches!(reg.run("note.pick", &mut ctx, &Value::Null), Err(Error::Disabled(id)) if id == "note.pick"));
|
|
assert_eq!(ctx.ran, Vec::<String>::new());
|
|
assert!(!reg.is_enabled("note.pick", &ctx));
|
|
assert!(!reg.is_enabled("nope", &ctx));
|
|
|
|
ctx.allow = true;
|
|
assert!(reg.is_enabled("note.pick", &ctx));
|
|
assert_eq!(reg.run("note.pick", &mut ctx, &Value::Null), Ok(Value::Null));
|
|
assert_eq!(reg.run("note.pick", &mut ctx, &json!({})), Ok(json!({})));
|
|
assert_eq!(ctx.ran.len(), 2);
|
|
|
|
for bad in [json!([1, 2]), json!("text"), json!(3), json!(true)] {
|
|
assert!(matches!(reg.run("note.pick", &mut ctx, &bad), Err(Error::InvalidParams { .. })), "params {bad} must be refused");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn params_are_validated_before_the_body_runs() {
|
|
let mut reg = Registry::new();
|
|
reg.register(
|
|
CommandSpec::new("note.echo", "Echo", "echoes", |_| true, record)
|
|
.with_param(ParamSpec::required("path", ParamKind::String, "note path"))
|
|
.with_param(ParamSpec::optional("count", ParamKind::Integer, "how many"))
|
|
.with_param(ParamSpec::optional("ratio", ParamKind::Number, "a number"))
|
|
.with_param(ParamSpec::optional("force", ParamKind::Bool, "a flag"))
|
|
.with_param(ParamSpec::optional("tags", ParamKind::Array, "a list"))
|
|
.with_param(ParamSpec::optional("extra", ParamKind::Object, "an object"))
|
|
.with_param(ParamSpec::optional("anything", ParamKind::Any, "anything")),
|
|
)
|
|
.expect("register");
|
|
let mut ctx = Ctx::default();
|
|
|
|
let good = json!({
|
|
"path": "a.md", "count": 3, "ratio": 1.5, "force": true,
|
|
"tags": ["x"], "extra": {}, "anything": null
|
|
});
|
|
assert_eq!(reg.run("note.echo", &mut ctx, &good), Ok(good.clone()));
|
|
assert_eq!(ctx.ran.len(), 1);
|
|
|
|
let bad_values = [
|
|
json!({}),
|
|
json!({"path": 1}),
|
|
json!({"path": "a", "count": 1.5}),
|
|
json!({"path": "a", "count": "3"}),
|
|
json!({"path": "a", "count": 9_223_372_036_854_775_808u64}),
|
|
json!({"path": "a", "ratio": "x"}),
|
|
json!({"path": "a", "force": "yes"}),
|
|
json!({"path": "a", "tags": {}}),
|
|
json!({"path": "a", "extra": []}),
|
|
json!({"path": "a", "typo": 1}),
|
|
json!({"path": null}),
|
|
];
|
|
for bad in bad_values {
|
|
let ran = ctx.ran.len();
|
|
assert!(matches!(reg.run("note.echo", &mut ctx, &bad), Err(Error::InvalidParams { .. })), "params {bad} must be refused");
|
|
assert_eq!(ctx.ran.len(), ran, "the body must not run for {bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn params_over_the_cap_are_refused_before_the_body_runs() {
|
|
let mut reg = Registry::new();
|
|
reg.register(CommandSpec::new("note.write", "Write", "writes", |_| true, record)).expect("register");
|
|
let mut ctx = Ctx::default();
|
|
let huge = json!({ "content": "x".repeat(notescraft_engine::MAX_PARAMS_BYTES + 1) });
|
|
assert!(matches!(reg.run("note.write", &mut ctx, &huge), Err(Error::ParamsTooLarge { .. })));
|
|
assert_eq!(ctx.ran, Vec::<String>::new());
|
|
}
|
|
|
|
#[test]
|
|
fn a_panicking_body_is_contained() {
|
|
let mut reg = registry_with(&["note.open"]);
|
|
reg.register(CommandSpec::new("note.panic", "Panic", "panics", |_| true, kaboom)).expect("register");
|
|
let mut ctx = Ctx::default();
|
|
let previous = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(|_| {}));
|
|
let result = reg.run("note.panic", &mut ctx, &Value::Null);
|
|
std::panic::set_hook(previous);
|
|
|
|
assert!(matches!(result, Err(Error::Panicked(id)) if id == "note.panic"));
|
|
assert_eq!(reg.run("note.open", &mut ctx, &Value::Null), Ok(Value::Null));
|
|
assert_eq!(reg.len(), 2);
|
|
assert!(ctx.ran.iter().any(|id| id == "record"));
|
|
}
|