//! 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"); }