Skip to main content

max / goingson

13.9 KB · 405 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(
53 "create_task",
54 json!({ "description": "nope" }),
55 Some(&HashSet::new()),
56 )
57 .await
58 .unwrap_err();
59 match err {
60 Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.task.create"),
61 other => panic!("expected CapabilityDenied, got {other:?}"),
62 }
63 }
64
65 #[tokio::test]
66 async fn create_project_is_idempotent_on_name() {
67 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
68
69 let first = call(&reg, "create_project", json!({ "name": "Kberg" })).await;
70 assert_eq!(first["created"], true);
71 let id1 = first["id"].as_str().unwrap().to_string();
72
73 let second = call(&reg, "create_project", json!({ "name": "Kberg" })).await;
74 assert_eq!(second["created"], false);
75 assert_eq!(second["id"].as_str().unwrap(), id1);
76
77 let projects = call(&reg, "list_projects", json!({})).await;
78 assert_eq!(projects["projects"].as_array().unwrap().len(), 1);
79 }
80
81 #[tokio::test]
82 async fn bulk_import_creates_dedupes_and_reconciles() {
83 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
84
85 let payload = json!({
86 "tasks": [
87 { "description": "migrate todos", "project": "dellm", "due": "2026-07-15",
88 "priority": "High", "tags": ["migration"], "source": "todo.md:10" },
89 { "description": "bridge rustdoc", "project": "dellm", "source": "todo.md:20" },
90 { "description": "no source, always inserts" }
91 ]
92 });
93
94 let first = call(&reg, "bulk_import_tasks", payload.clone()).await;
95 assert_eq!(first["created"], 3);
96 assert_eq!(first["skipped"], 0);
97
98 // Re-run: the two sourced items dedupe; the source-less one inserts again.
99 let second = call(&reg, "bulk_import_tasks", payload).await;
100 assert_eq!(second["created"], 1, "only the source-less task re-inserts");
101 assert_eq!(second["skipped"], 2, "both sourced tasks are skipped");
102
103 // Project was auto-created and reused (not duplicated).
104 let projects = call(&reg, "list_projects", json!({})).await;
105 let names: Vec<&str> = projects["projects"]
106 .as_array()
107 .unwrap()
108 .iter()
109 .map(|p| p["name"].as_str().unwrap())
110 .collect();
111 assert_eq!(names, vec!["dellm"]);
112
113 // Reconciliation: 4 tasks total, filterable by project and tag.
114 let all = call(&reg, "list_tasks", json!({})).await;
115 assert_eq!(all["count"], 4);
116 let in_project = call(&reg, "list_tasks", json!({ "project": "dellm" })).await;
117 assert_eq!(in_project["count"], 2);
118 let tagged = call(&reg, "list_tasks", json!({ "tag": "migration" })).await;
119 assert_eq!(tagged["count"], 1);
120
121 // The high-priority task carries its due date and provenance tag.
122 let hi = &tagged["tasks"][0];
123 assert_eq!(hi["priority"], "High");
124 assert!(hi["due"].as_str().unwrap().starts_with("2026-07-15"));
125 let tags: Vec<&str> = hi["tags"]
126 .as_array()
127 .unwrap()
128 .iter()
129 .map(|t| t.as_str().unwrap())
130 .collect();
131 assert!(tags.contains(&"source:todo.md:10"));
132 }
133
134 #[tokio::test]
135 async fn get_update_and_complete_round_trip() {
136 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
137
138 let created = call(
139 &reg,
140 "create_task",
141 json!({ "description": "draft", "priority": "Low" }),
142 )
143 .await;
144 let id = created["id"].as_str().unwrap().to_string();
145
146 let got = call(&reg, "get_task", json!({ "id": id })).await;
147 assert_eq!(got["description"], "draft");
148 assert_eq!(got["priority"], "Low");
149 assert!(got["subtasks"].is_array());
150
151 let updated = call(
152 &reg,
153 "update_task",
154 json!({ "id": id, "priority": "High", "description": "final draft" }),
155 )
156 .await;
157 assert_eq!(updated["priority"], "High");
158 assert_eq!(updated["description"], "final draft");
159
160 let done = call(&reg, "complete_task", json!({ "id": id })).await;
161 assert_eq!(done["status"], "Completed");
162 }
163
164 #[tokio::test]
165 async fn list_tasks_pages_and_clips_long_descriptions() {
166 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
167
168 // 120 tasks: more than the default page, less than two max pages.
169 let items: Vec<Value> = (0..120)
170 .map(|i| json!({ "description": format!("task {i}"), "project": "bulk" }))
171 .collect();
172 call(&reg, "bulk_import_tasks", json!({ "tasks": items })).await;
173
174 // An unparameterized call is bounded by the default, and says so.
175 let first = call(&reg, "list_tasks", json!({})).await;
176 assert_eq!(
177 first["count"], 50,
178 "default limit bounds an unasked-for page"
179 );
180 assert_eq!(first["total"], 120, "total reports every matching row");
181 assert_eq!(first["offset"], 0);
182 assert_eq!(first["next_offset"], 50);
183
184 // Walking next_offset visits every row exactly once and then stops.
185 let mut seen: HashSet<String> = HashSet::new();
186 let mut offset = 0u64;
187 loop {
188 let page = call(&reg, "list_tasks", json!({ "limit": 30, "offset": offset })).await;
189 for t in page["tasks"].as_array().unwrap() {
190 assert!(
191 seen.insert(t["id"].as_str().unwrap().to_string()),
192 "row repeated across pages"
193 );
194 }
195 match page["next_offset"].as_u64() {
196 Some(next) => offset = next,
197 None => break,
198 }
199 }
200 assert_eq!(seen.len(), 120, "paging covered every row");
201
202 // The cap clamps rather than erroring; a bogus limit errors rather than defaulting.
203 let clamped = call(&reg, "list_tasks", json!({ "limit": 5_000 })).await;
204 assert_eq!(
205 clamped["count"], 120,
206 "clamped to max, which exceeds the row count here"
207 );
208 let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
209 let err = reg
210 .call("list_tasks", json!({ "limit": 0 }), Some(&grants))
211 .await
212 .unwrap_err();
213 assert!(
214 matches!(err, Error::InvalidArgs { .. }),
215 "zero limit is a typo, not a default"
216 );
217 }
218
219 #[tokio::test]
220 async fn list_tasks_clips_a_long_description_but_get_task_keeps_it() {
221 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
222
223 let long = "x".repeat(1_000);
224 let created = call(&reg, "create_task", json!({ "description": long })).await;
225 let id = created["id"].as_str().unwrap().to_string();
226
227 let listed = &call(&reg, "list_tasks", json!({})).await["tasks"][0];
228 assert_eq!(
229 listed["truncated"], true,
230 "a clipped row admits it is clipped"
231 );
232 assert_eq!(listed["description"].as_str().unwrap().chars().count(), 240);
233
234 // get_task is the escape hatch the clip points at: full text, no marker.
235 let got = call(&reg, "get_task", json!({ "id": id })).await;
236 assert_eq!(got["description"].as_str().unwrap().chars().count(), 1_000);
237 assert!(got.get("truncated").is_none());
238
239 // A short description is untouched and unmarked.
240 call(&reg, "create_task", json!({ "description": "short" })).await;
241 let rows = call(&reg, "list_tasks", json!({})).await;
242 let short = rows["tasks"]
243 .as_array()
244 .unwrap()
245 .iter()
246 .find(|t| t["description"] == "short")
247 .expect("short task listed");
248 assert!(short.get("truncated").is_none());
249 }
250
251 #[tokio::test]
252 async fn update_project_overlays_fields_and_rejects_a_bad_status() {
253 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
254
255 call(
256 &reg,
257 "create_project",
258 json!({
259 "name": "dawless", "type": "SideProject", "description": "a synth thing"
260 }),
261 )
262 .await;
263
264 // The motivating case: retire a dormant project without touching the desktop UI.
265 let archived = call(
266 &reg,
267 "update_project",
268 json!({
269 "project": "dawless", "status": "Archived"
270 }),
271 )
272 .await;
273 assert_eq!(archived["status"], "Archived");
274 assert_eq!(
275 archived["name"], "dawless",
276 "name is the resolution key, untouched"
277 );
278 assert_eq!(
279 archived["description"], "a synth thing",
280 "unpassed fields keep their value"
281 );
282 assert_eq!(archived["type"], "SideProject");
283
284 // It stuck, and is visible to a fresh read.
285 let listed = call(&reg, "list_projects", json!({})).await;
286 assert_eq!(listed["projects"][0]["status"], "Archived");
287
288 // A typo'd status must fail loudly. Defaulting it would silently flip the
289 // project back to Active while reporting success.
290 let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
291 let err = reg
292 .call(
293 "update_project",
294 json!({ "project": "dawless", "status": "archivd" }),
295 Some(&grants),
296 )
297 .await
298 .unwrap_err();
299 assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}");
300 let after = call(&reg, "list_projects", json!({})).await;
301 assert_eq!(
302 after["projects"][0]["status"], "Archived",
303 "the failed call changed nothing"
304 );
305
306 // Unknown project names are an error, never a silent create.
307 let err = reg
308 .call(
309 "update_project",
310 json!({ "project": "ghost", "status": "Active" }),
311 Some(&grants),
312 )
313 .await
314 .unwrap_err();
315 assert!(matches!(err, Error::ToolFailed { .. }), "got {err:?}");
316 assert_eq!(
317 call(&reg, "list_projects", json!({})).await["projects"]
318 .as_array()
319 .unwrap()
320 .len(),
321 1
322 );
323 }
324
325 #[tokio::test]
326 async fn update_project_is_capability_gated() {
327 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
328 let err = reg
329 .call(
330 "update_project",
331 json!({ "project": "x", "status": "Archived" }),
332 Some(&HashSet::new()),
333 )
334 .await
335 .unwrap_err();
336 match err {
337 Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.project.update"),
338 other => panic!("expected CapabilityDenied, got {other:?}"),
339 }
340 }
341
342 /// The values the read tools emit must be the values the write tools accept.
343 /// `ProjectType::as_str` is a display string ("Side Project") that `FromStr`
344 /// rejects, so emitting it would break any caller that echoes a row back.
345 #[tokio::test]
346 async fn project_enum_values_round_trip_through_the_write_surface() {
347 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
348
349 call(
350 &reg,
351 "create_project",
352 json!({ "name": "roundtrip", "type": "SideProject" }),
353 )
354 .await;
355 call(
356 &reg,
357 "update_project",
358 json!({ "project": "roundtrip", "status": "OnHold" }),
359 )
360 .await;
361
362 let listed = &call(&reg, "list_projects", json!({})).await["projects"][0];
363 assert_eq!(
364 listed["type"], "SideProject",
365 "not the display form `Side Project`"
366 );
367 assert_eq!(listed["status"], "OnHold", "not the display form `On Hold`");
368
369 // Feeding the emitted row straight back must be accepted verbatim.
370 let echoed = call(
371 &reg,
372 "update_project",
373 json!({
374 "project": "roundtrip",
375 "type": listed["type"].clone(),
376 "status": listed["status"].clone(),
377 }),
378 )
379 .await;
380 assert_eq!(echoed["type"], "SideProject");
381 assert_eq!(echoed["status"], "OnHold");
382 }
383
384 /// `Writing` is not a ProjectType variant. It used to parse via
385 /// `from_str_or_default` into `Job` (the derived Default) and report success.
386 #[tokio::test]
387 async fn create_project_rejects_an_unknown_type_instead_of_defaulting() {
388 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
389 let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
390
391 let err = reg
392 .call(
393 "create_project",
394 json!({ "name": "essays", "type": "Writing" }),
395 Some(&grants),
396 )
397 .await
398 .unwrap_err();
399 assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}");
400
401 // And the rejected call created nothing.
402 let listed = call(&reg, "list_projects", json!({})).await;
403 assert!(listed["projects"].as_array().unwrap().is_empty());
404 }
405