Skip to main content

max / goingson

5.7 KB · 149 lines History Blame Raw
1 //! End-to-end tests: seed an in-memory GoingsOn database (real migrations +
2 //! the fixed desktop user), then drive the tools through a kberg `ToolRegistry`
3 //! exactly as the HTTP server would, asserting capability gating, idempotent
4 //! bulk import, and read-back reconciliation.
5
6 use std::collections::HashSet;
7 use std::sync::Arc;
8
9 use go_mcp::context::{Ctx, DESKTOP_USER_ID};
10 use go_mcp::tools;
11 use kberg::Error;
12 use serde_json::{Value, json};
13 use sqlx::SqlitePool;
14
15 async fn seed_db() -> SqlitePool {
16 let pool = goingson_db_sqlite::init_pool(Some(":memory:"))
17 .await
18 .expect("open in-memory db");
19 goingson_db_sqlite::run_migrations(&pool)
20 .await
21 .expect("run migrations");
22 // Fixed single-user desktop row, mirroring the app's ensure_desktop_user.
23 sqlx::query(
24 "INSERT INTO users (id, email, password_hash, display_name, created_at) \
25 VALUES (?, 'desktop@localhost', 'x', 'Desktop User', datetime('now'))",
26 )
27 .bind(DESKTOP_USER_ID.to_string())
28 .execute(&pool)
29 .await
30 .expect("seed desktop user");
31 pool
32 }
33
34 /// Call a tool through the registry with all writes granted, unwrapping the
35 /// JSON text result.
36 async fn call(reg: &kberg::ToolRegistry, name: &str, args: Value) -> Value {
37 let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
38 let out = reg
39 .call(name, args, Some(&grants))
40 .await
41 .unwrap_or_else(|e| panic!("{name} failed: {e}"));
42 assert!(!out.is_error, "{name} returned is_error");
43 let kberg::ContentPart::Text { text } = &out.content[0];
44 serde_json::from_str(text).expect("tool result is JSON")
45 }
46
47 #[tokio::test]
48 async fn write_is_denied_without_the_capability() {
49 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
50 // Empty grant set: create_task must be refused by kberg before it runs.
51 let err = reg
52 .call("create_task", json!({ "description": "nope" }), Some(&HashSet::new()))
53 .await
54 .unwrap_err();
55 match err {
56 Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.task.create"),
57 other => panic!("expected CapabilityDenied, got {other:?}"),
58 }
59 }
60
61 #[tokio::test]
62 async fn create_project_is_idempotent_on_name() {
63 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
64
65 let first = call(&reg, "create_project", json!({ "name": "Kberg" })).await;
66 assert_eq!(first["created"], true);
67 let id1 = first["id"].as_str().unwrap().to_string();
68
69 let second = call(&reg, "create_project", json!({ "name": "Kberg" })).await;
70 assert_eq!(second["created"], false);
71 assert_eq!(second["id"].as_str().unwrap(), id1);
72
73 let projects = call(&reg, "list_projects", json!({})).await;
74 assert_eq!(projects["projects"].as_array().unwrap().len(), 1);
75 }
76
77 #[tokio::test]
78 async fn bulk_import_creates_dedupes_and_reconciles() {
79 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
80
81 let payload = json!({
82 "tasks": [
83 { "description": "migrate todos", "project": "dellm", "due": "2026-07-15",
84 "priority": "High", "tags": ["migration"], "source": "todo.md:10" },
85 { "description": "bridge rustdoc", "project": "dellm", "source": "todo.md:20" },
86 { "description": "no source, always inserts" }
87 ]
88 });
89
90 let first = call(&reg, "bulk_import_tasks", payload.clone()).await;
91 assert_eq!(first["created"], 3);
92 assert_eq!(first["skipped"], 0);
93
94 // Re-run: the two sourced items dedupe; the source-less one inserts again.
95 let second = call(&reg, "bulk_import_tasks", payload).await;
96 assert_eq!(second["created"], 1, "only the source-less task re-inserts");
97 assert_eq!(second["skipped"], 2, "both sourced tasks are skipped");
98
99 // Project was auto-created and reused (not duplicated).
100 let projects = call(&reg, "list_projects", json!({})).await;
101 let names: Vec<&str> = projects["projects"]
102 .as_array()
103 .unwrap()
104 .iter()
105 .map(|p| p["name"].as_str().unwrap())
106 .collect();
107 assert_eq!(names, vec!["dellm"]);
108
109 // Reconciliation: 4 tasks total, filterable by project and tag.
110 let all = call(&reg, "list_tasks", json!({})).await;
111 assert_eq!(all["count"], 4);
112 let in_project = call(&reg, "list_tasks", json!({ "project": "dellm" })).await;
113 assert_eq!(in_project["count"], 2);
114 let tagged = call(&reg, "list_tasks", json!({ "tag": "migration" })).await;
115 assert_eq!(tagged["count"], 1);
116
117 // The high-priority task carries its due date and provenance tag.
118 let hi = &tagged["tasks"][0];
119 assert_eq!(hi["priority"], "High");
120 assert!(hi["due"].as_str().unwrap().starts_with("2026-07-15"));
121 let tags: Vec<&str> = hi["tags"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect();
122 assert!(tags.contains(&"source:todo.md:10"));
123 }
124
125 #[tokio::test]
126 async fn get_update_and_complete_round_trip() {
127 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
128
129 let created = call(&reg, "create_task", json!({ "description": "draft", "priority": "Low" })).await;
130 let id = created["id"].as_str().unwrap().to_string();
131
132 let got = call(&reg, "get_task", json!({ "id": id })).await;
133 assert_eq!(got["description"], "draft");
134 assert_eq!(got["priority"], "Low");
135 assert!(got["subtasks"].is_array());
136
137 let updated = call(
138 &reg,
139 "update_task",
140 json!({ "id": id, "priority": "High", "description": "final draft" }),
141 )
142 .await;
143 assert_eq!(updated["priority"], "High");
144 assert_eq!(updated["description"], "final draft");
145
146 let done = call(&reg, "complete_task", json!({ "id": id })).await;
147 assert_eq!(done["status"], "Completed");
148 }
149