//! update_idea/update_project/update_task 部分更新回归测试 //! //! 回归 P0 bug:LLM 客户端做部分更新(只传 description 不传 title)时, //! 旧实现用 `arg_str_or(args, "title", "")` 取值,缺省 → 空串覆盖 existing //! → title 被静默清空 → 数据丢失。 //! //! 根本修:title/description(name)缺省回退 existing,而非空默认覆盖。 //! 本测试覆盖三个 handler 的「只传一个字段,另一字段保留 existing」语义。 use df_mcp::tools::{find, Ctx}; use df_storage::db::Database; use serde_json::{json, Value}; /// 从 CallToolResult 取首个 text 块解析为 JSON。 fn result_json(res: &df_mcp::protocol::CallToolResult) -> Value { assert!( res.is_error != Some(true), "工具调用失败(is_error=true): {:?}", res.content ); match res.content.first() { Some(df_mcp::protocol::ContentBlock::Text { text }) => { serde_json::from_str(text).expect("响应非合法 JSON") } other => panic!("预期 Text 块,实际: {other:?}"), } } /// 取嵌套对象 record(title/name/description 等业务字段在其下)。 fn record_of(v: &Value, key: &str) -> Value { v.get(key) .cloned() .unwrap_or_else(|| panic!("响应缺 `{key}` 字段: {v}")) } async fn setup() -> Ctx { let db = Database::open_in_memory().await.expect("open_in_memory"); Ctx::new(std::sync::Arc::new(db)) } async fn call(ctx: &Ctx, name: &str, args: Value) -> Value { let spec = find(name).expect("工具已注册"); let res = (spec.handler)(ctx, args).await; result_json(&res) } // ============================================================ // update_idea:只传 description,title 必须保留 existing // ============================================================ #[tokio::test] async fn update_idea_keeps_title_when_only_description_sent() { let ctx = setup().await; // 先建一条想法:title="原始标题" let created = call( &ctx, "create_idea", json!({ "title": "原始标题", "description": "原始描述" }), ) .await; let id = created["id"].as_str().expect("id").to_owned(); // LLM 只传 description(不传 title)—— 旧实现会把 title 清空为 "" let updated = call( &ctx, "update_idea", json!({ "id": id, "description": "新描述" }), ) .await; let idea = record_of(&updated, "idea"); assert_eq!(idea["title"].as_str(), Some("原始标题"), "title 应保留 existing,不被空默认清空"); assert_eq!(idea["description"].as_str(), Some("新描述"), "description 应更新为新值"); } #[tokio::test] async fn update_idea_keeps_description_when_only_title_sent() { let ctx = setup().await; let created = call( &ctx, "create_idea", json!({ "title": "原标题", "description": "原描述" }), ) .await; let id = created["id"].as_str().expect("id").to_owned(); let updated = call(&ctx, "update_idea", json!({ "id": id, "title": "新标题" })).await; let idea = record_of(&updated, "idea"); assert_eq!(idea["title"].as_str(), Some("新标题")); assert_eq!(idea["description"].as_str(), Some("原描述"), "description 应保留 existing"); } // ============================================================ // update_project:只传 description,name 必须保留 existing // ============================================================ #[tokio::test] async fn update_project_keeps_name_when_only_description_sent() { let ctx = setup().await; let created = call( &ctx, "create_project", json!({ "name": "原始项目", "description": "原始描述" }), ) .await; let id = created["id"].as_str().expect("id").to_owned(); let updated = call( &ctx, "update_project", json!({ "id": id, "description": "新描述" }), ) .await; let project = record_of(&updated, "project"); assert_eq!(project["name"].as_str(), Some("原始项目"), "name 应保留 existing"); assert_eq!(project["description"].as_str(), Some("新描述")); // path/stack/idea_id 未传也应保留(existing 创建时为 None,这里间接保证不被改) assert_eq!(project["path"].as_str(), None); } #[tokio::test] async fn update_project_keeps_status_when_not_sent() { // 状态字段缺省同样应保留 existing(旧实现默认 "planning" 会重置状态) let ctx = setup().await; let created = call( &ctx, "create_project", json!({ "name": "P", "description": "D", "status": "in_progress" }), ) .await; let id = created["id"].as_str().expect("id").to_owned(); let updated = call( &ctx, "update_project", json!({ "id": id, "description": "改描述" }), ) .await; let project = record_of(&updated, "project"); assert_eq!( project["status"].as_str(), Some("in_progress"), "status 应保留 existing,不被默认 planning 重置" ); } // ============================================================ // update_task:只传 description,title/project_id 必须保留 existing // ============================================================ #[tokio::test] async fn update_task_keeps_title_and_project_when_only_description_sent() { let ctx = setup().await; let proj = call( &ctx, "create_project", json!({ "name": "所属项目", "description": "d" }), ) .await; let project_id = proj["id"].as_str().expect("project id").to_owned(); let created = call( &ctx, "create_task", json!({ "project_id": project_id, "title": "原始任务标题", "description": "原始描述" }), ) .await; let id = created["id"].as_str().expect("id").to_owned(); // 只传 description:旧实现 title 是必填会报错,description 缺省会清空(行为不一) // 根本修后三者都应保留 existing(或更新为新值) let updated = call( &ctx, "update_task", json!({ "id": id, "description": "新描述" }), ) .await; let task = record_of(&updated, "task"); assert_eq!(task["title"].as_str(), Some("原始任务标题"), "title 应保留 existing"); assert_eq!(task["description"].as_str(), Some("新描述")); assert_eq!(task["project_id"].as_str(), Some(project_id.as_str()), "project_id 应保留 existing"); // status 走状态机,update_task 不改也应保留 assert_eq!(task["status"].as_str(), Some("todo")); } #[tokio::test] async fn update_task_keeps_description_when_only_title_sent() { let ctx = setup().await; let proj = call( &ctx, "create_project", json!({ "name": "P2", "description": "d" }), ) .await; let project_id = proj["id"].as_str().expect("project id").to_owned(); let created = call( &ctx, "create_task", json!({ "project_id": project_id, "title": "原标题", "description": "原描述" }), ) .await; let id = created["id"].as_str().expect("id").to_owned(); let updated = call(&ctx, "update_task", json!({ "id": id, "title": "新标题" })).await; let task = record_of(&updated, "task"); assert_eq!(task["title"].as_str(), Some("新标题")); assert_eq!(task["description"].as_str(), Some("原描述"), "description 应保留 existing"); }