//! 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, last: Option, } 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 { CommandSpec::new(id, format!("label {id}"), format!("doc {id}"), |_| true, record) } fn registry_with(ids: &[&str]) -> Registry { 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 = 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::::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::::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")); }