//! End-to-end tests: seed an in-memory GoingsOn database (real migrations + //! the fixed desktop user), then drive the tools through a kberg `ToolRegistry` //! exactly as the HTTP server would, asserting capability gating, idempotent //! bulk import, and read-back reconciliation. use std::collections::HashSet; use std::sync::Arc; use go_mcp::context::{Ctx, DESKTOP_USER_ID}; use go_mcp::tools; use kberg::Error; use serde_json::{Value, json}; use sqlx::SqlitePool; async fn seed_db() -> SqlitePool { let pool = goingson_db_sqlite::init_pool(Some(":memory:")) .await .expect("open in-memory db"); goingson_db_sqlite::run_migrations(&pool) .await .expect("run migrations"); // Fixed single-user desktop row, mirroring the app's ensure_desktop_user. sqlx::query( "INSERT INTO users (id, email, password_hash, display_name, created_at) \ VALUES (?, 'desktop@localhost', 'x', 'Desktop User', datetime('now'))", ) .bind(DESKTOP_USER_ID.to_string()) .execute(&pool) .await .expect("seed desktop user"); pool } /// Call a tool through the registry with all writes granted, unwrapping the /// JSON text result. async fn call(reg: &kberg::ToolRegistry, name: &str, args: Value) -> Value { let grants: HashSet = reg.write_capabilities().into_iter().map(|c| c.id).collect(); let out = reg .call(name, args, Some(&grants)) .await .unwrap_or_else(|e| panic!("{name} failed: {e}")); assert!(!out.is_error, "{name} returned is_error"); let kberg::ContentPart::Text { text } = &out.content[0]; serde_json::from_str(text).expect("tool result is JSON") } #[tokio::test] async fn write_is_denied_without_the_capability() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); // Empty grant set: create_task must be refused by kberg before it runs. let err = reg .call( "create_task", json!({ "description": "nope" }), Some(&HashSet::new()), ) .await .unwrap_err(); match err { Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.task.create"), other => panic!("expected CapabilityDenied, got {other:?}"), } } #[tokio::test] async fn create_project_is_idempotent_on_name() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); let first = call(®, "create_project", json!({ "name": "Kberg" })).await; assert_eq!(first["created"], true); let id1 = first["id"].as_str().unwrap().to_string(); let second = call(®, "create_project", json!({ "name": "Kberg" })).await; assert_eq!(second["created"], false); assert_eq!(second["id"].as_str().unwrap(), id1); let projects = call(®, "list_projects", json!({})).await; assert_eq!(projects["projects"].as_array().unwrap().len(), 1); } #[tokio::test] async fn bulk_import_creates_dedupes_and_reconciles() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); let payload = json!({ "tasks": [ { "description": "migrate todos", "project": "dellm", "due": "2026-07-15", "priority": "High", "tags": ["migration"], "source": "todo.md:10" }, { "description": "bridge rustdoc", "project": "dellm", "source": "todo.md:20" }, { "description": "no source, always inserts" } ] }); let first = call(®, "bulk_import_tasks", payload.clone()).await; assert_eq!(first["created"], 3); assert_eq!(first["skipped"], 0); // Re-run: the two sourced items dedupe; the source-less one inserts again. let second = call(®, "bulk_import_tasks", payload).await; assert_eq!(second["created"], 1, "only the source-less task re-inserts"); assert_eq!(second["skipped"], 2, "both sourced tasks are skipped"); // Project was auto-created and reused (not duplicated). let projects = call(®, "list_projects", json!({})).await; let names: Vec<&str> = projects["projects"] .as_array() .unwrap() .iter() .map(|p| p["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["dellm"]); // Reconciliation: 4 tasks total, filterable by project and tag. let all = call(®, "list_tasks", json!({})).await; assert_eq!(all["count"], 4); let in_project = call(®, "list_tasks", json!({ "project": "dellm" })).await; assert_eq!(in_project["count"], 2); let tagged = call(®, "list_tasks", json!({ "tag": "migration" })).await; assert_eq!(tagged["count"], 1); // The high-priority task carries its due date and provenance tag. let hi = &tagged["tasks"][0]; assert_eq!(hi["priority"], "High"); assert!(hi["due"].as_str().unwrap().starts_with("2026-07-15")); let tags: Vec<&str> = hi["tags"] .as_array() .unwrap() .iter() .map(|t| t.as_str().unwrap()) .collect(); assert!(tags.contains(&"source:todo.md:10")); } #[tokio::test] async fn get_update_and_complete_round_trip() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); let created = call( ®, "create_task", json!({ "description": "draft", "priority": "Low" }), ) .await; let id = created["id"].as_str().unwrap().to_string(); let got = call(®, "get_task", json!({ "id": id })).await; assert_eq!(got["description"], "draft"); assert_eq!(got["priority"], "Low"); assert!(got["subtasks"].is_array()); let updated = call( ®, "update_task", json!({ "id": id, "priority": "High", "description": "final draft" }), ) .await; assert_eq!(updated["priority"], "High"); assert_eq!(updated["description"], "final draft"); let done = call(®, "complete_task", json!({ "id": id })).await; assert_eq!(done["status"], "Completed"); } #[tokio::test] async fn list_tasks_pages_and_clips_long_descriptions() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); // 120 tasks: more than the default page, less than two max pages. let items: Vec = (0..120) .map(|i| json!({ "description": format!("task {i}"), "project": "bulk" })) .collect(); call(®, "bulk_import_tasks", json!({ "tasks": items })).await; // An unparameterized call is bounded by the default, and says so. let first = call(®, "list_tasks", json!({})).await; assert_eq!( first["count"], 50, "default limit bounds an unasked-for page" ); assert_eq!(first["total"], 120, "total reports every matching row"); assert_eq!(first["offset"], 0); assert_eq!(first["next_offset"], 50); // Walking next_offset visits every row exactly once and then stops. let mut seen: HashSet = HashSet::new(); let mut offset = 0u64; loop { let page = call(®, "list_tasks", json!({ "limit": 30, "offset": offset })).await; for t in page["tasks"].as_array().unwrap() { assert!( seen.insert(t["id"].as_str().unwrap().to_string()), "row repeated across pages" ); } match page["next_offset"].as_u64() { Some(next) => offset = next, None => break, } } assert_eq!(seen.len(), 120, "paging covered every row"); // The cap clamps rather than erroring; a bogus limit errors rather than defaulting. let clamped = call(®, "list_tasks", json!({ "limit": 5_000 })).await; assert_eq!( clamped["count"], 120, "clamped to max, which exceeds the row count here" ); let grants: HashSet = reg.write_capabilities().into_iter().map(|c| c.id).collect(); let err = reg .call("list_tasks", json!({ "limit": 0 }), Some(&grants)) .await .unwrap_err(); assert!( matches!(err, Error::InvalidArgs { .. }), "zero limit is a typo, not a default" ); } #[tokio::test] async fn list_tasks_clips_a_long_description_but_get_task_keeps_it() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); let long = "x".repeat(1_000); let created = call(®, "create_task", json!({ "description": long })).await; let id = created["id"].as_str().unwrap().to_string(); let listed = &call(®, "list_tasks", json!({})).await["tasks"][0]; assert_eq!( listed["truncated"], true, "a clipped row admits it is clipped" ); assert_eq!(listed["description"].as_str().unwrap().chars().count(), 240); // get_task is the escape hatch the clip points at: full text, no marker. let got = call(®, "get_task", json!({ "id": id })).await; assert_eq!(got["description"].as_str().unwrap().chars().count(), 1_000); assert!(got.get("truncated").is_none()); // A short description is untouched and unmarked. call(®, "create_task", json!({ "description": "short" })).await; let rows = call(®, "list_tasks", json!({})).await; let short = rows["tasks"] .as_array() .unwrap() .iter() .find(|t| t["description"] == "short") .expect("short task listed"); assert!(short.get("truncated").is_none()); } #[tokio::test] async fn update_project_overlays_fields_and_rejects_a_bad_status() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); call( ®, "create_project", json!({ "name": "dawless", "type": "SideProject", "description": "a synth thing" }), ) .await; // The motivating case: retire a dormant project without touching the desktop UI. let archived = call( ®, "update_project", json!({ "project": "dawless", "status": "Archived" }), ) .await; assert_eq!(archived["status"], "Archived"); assert_eq!( archived["name"], "dawless", "name is the resolution key, untouched" ); assert_eq!( archived["description"], "a synth thing", "unpassed fields keep their value" ); assert_eq!(archived["type"], "SideProject"); // It stuck, and is visible to a fresh read. let listed = call(®, "list_projects", json!({})).await; assert_eq!(listed["projects"][0]["status"], "Archived"); // A typo'd status must fail loudly. Defaulting it would silently flip the // project back to Active while reporting success. let grants: HashSet = reg.write_capabilities().into_iter().map(|c| c.id).collect(); let err = reg .call( "update_project", json!({ "project": "dawless", "status": "archivd" }), Some(&grants), ) .await .unwrap_err(); assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}"); let after = call(®, "list_projects", json!({})).await; assert_eq!( after["projects"][0]["status"], "Archived", "the failed call changed nothing" ); // Unknown project names are an error, never a silent create. let err = reg .call( "update_project", json!({ "project": "ghost", "status": "Active" }), Some(&grants), ) .await .unwrap_err(); assert!(matches!(err, Error::ToolFailed { .. }), "got {err:?}"); assert_eq!( call(®, "list_projects", json!({})).await["projects"] .as_array() .unwrap() .len(), 1 ); } #[tokio::test] async fn update_project_is_capability_gated() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); let err = reg .call( "update_project", json!({ "project": "x", "status": "Archived" }), Some(&HashSet::new()), ) .await .unwrap_err(); match err { Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.project.update"), other => panic!("expected CapabilityDenied, got {other:?}"), } } /// The values the read tools emit must be the values the write tools accept. /// `ProjectType::as_str` is a display string ("Side Project") that `FromStr` /// rejects, so emitting it would break any caller that echoes a row back. #[tokio::test] async fn project_enum_values_round_trip_through_the_write_surface() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); call( ®, "create_project", json!({ "name": "roundtrip", "type": "SideProject" }), ) .await; call( ®, "update_project", json!({ "project": "roundtrip", "status": "OnHold" }), ) .await; let listed = &call(®, "list_projects", json!({})).await["projects"][0]; assert_eq!( listed["type"], "SideProject", "not the display form `Side Project`" ); assert_eq!(listed["status"], "OnHold", "not the display form `On Hold`"); // Feeding the emitted row straight back must be accepted verbatim. let echoed = call( ®, "update_project", json!({ "project": "roundtrip", "type": listed["type"].clone(), "status": listed["status"].clone(), }), ) .await; assert_eq!(echoed["type"], "SideProject"); assert_eq!(echoed["status"], "OnHold"); } /// `Writing` is not a ProjectType variant. It used to parse via /// `from_str_or_default` into `Job` (the derived Default) and report success. #[tokio::test] async fn create_project_rejects_an_unknown_type_instead_of_defaulting() { let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); let grants: HashSet = reg.write_capabilities().into_iter().map(|c| c.id).collect(); let err = reg .call( "create_project", json!({ "name": "essays", "type": "Writing" }), Some(&grants), ) .await .unwrap_err(); assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}"); // And the rejected call created nothing. let listed = call(®, "list_projects", json!({})).await; assert!(listed["projects"].as_array().unwrap().is_empty()); }