//! ProjectRepo 软删回收站 + 级联清理 的集成测试。 //! //! 覆盖三类行为: //! 1. purge_with_descendants — 事务级联删 branches→releases→tasks→projects。 //! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。 //! 3. allowed_columns_for 按表隔离 — update_field 误传跨表列名在白名单阶段被拒。 use df_storage::crud::{ BranchRepo, ModuleDependencyRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo, ProjectRepo, ProjectServiceRepo, ReleaseRepo, TaskLinkRepo, TaskRepo, WorkflowRepo, }; use df_storage::db::Database; use df_storage::models::{ BranchRecord, ModuleDependencyRecord, NodeExecutionRecord, ProjectEventRecord, ProjectModuleRecord, ProjectRecord, ProjectServiceRecord, ReleaseRecord, TaskRecord, WorkflowRecord, }; use df_types::types::{ProjectStatus, TaskStatus}; // ---------- fixtures ---------- fn now_ts() -> String { "1700000000000".to_string() } fn project(id: &str) -> ProjectRecord { ProjectRecord { id: id.to_string(), name: format!("proj-{id}"), description: "desc".to_string(), status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, created_at: now_ts(), updated_at: now_ts(), } } fn task(id: &str, project_id: &str) -> TaskRecord { TaskRecord { id: id.to_string(), project_id: project_id.to_string(), title: format!("task-{id}"), description: "desc".to_string(), status: TaskStatus::Todo, priority: 1, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, review_rounds: 0, output_json: None, idea_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, module_id: None, created_at: now_ts(), updated_at: now_ts(), } } fn release(id: &str, project_id: &str) -> ReleaseRecord { ReleaseRecord { id: id.to_string(), project_id: project_id.to_string(), version: "0.1.0".to_string(), status: "draft".to_string(), task_ids: "[]".to_string(), changelog: None, created_at: now_ts(), released_at: None, } } fn branch(id: &str, project_id: &str) -> BranchRecord { BranchRecord { id: id.to_string(), project_id: project_id.to_string(), task_id: None, name: format!("br-{id}"), base: "main".to_string(), status: "open".to_string(), created_at: now_ts(), updated_at: now_ts(), merged_at: None, } } async fn setup() -> (ProjectRepo, TaskRepo, ReleaseRepo, BranchRepo) { let db = Database::open_in_memory().await.expect("open_in_memory"); ( ProjectRepo::new(&db), TaskRepo::new(&db), ReleaseRepo::new(&db), BranchRepo::new(&db), ) } // ============================================================ // 1. purge_with_descendants — 级联删除 // ============================================================ #[tokio::test] async fn purge_with_descendants_removes_project_and_all_children() { let (projects, tasks, releases, branches) = setup().await; projects.insert(project("p1")).await.unwrap(); tasks.insert(task("t1", "p1")).await.unwrap(); tasks.insert(task("t2", "p1")).await.unwrap(); releases.insert(release("r1", "p1")).await.unwrap(); branches.insert(branch("b1", "p1")).await.unwrap(); branches.insert(branch("b2", "p1")).await.unwrap(); // 另一个项目的子记录不应被波及(只删 p1 的) projects.insert(project("p2")).await.unwrap(); tasks.insert(task("t3", "p2")).await.unwrap(); let affected = projects.purge_with_descendants("p1").await.unwrap(); assert!(affected, "应命中 p1"); // 四表对应记录全消失 assert!(projects.get_by_id("p1").await.unwrap().is_none()); assert!(tasks.get_by_id("t1").await.unwrap().is_none()); assert!(tasks.get_by_id("t2").await.unwrap().is_none()); assert!(releases.get_by_id("r1").await.unwrap().is_none()); assert!(branches.get_by_id("b1").await.unwrap().is_none()); assert!(branches.get_by_id("b2").await.unwrap().is_none()); // 邻居项目 p2 完好 assert!(projects.get_by_id("p2").await.unwrap().is_some()); assert!(tasks.get_by_id("t3").await.unwrap().is_some()); } #[tokio::test] async fn purge_with_descendants_unknown_id_returns_false() { let (projects, _t, _r, _b) = setup().await; let affected = projects.purge_with_descendants("ghost").await.unwrap(); assert!(!affected, "不存在的 id 不命中"); } #[tokio::test] async fn purge_with_descendants_is_atomic_on_missing_child_table_row() { // 事务性验证:即使 project 存在但被删语句之间无冲突,purge 应整体成功或整体不变。 // 这里验证 commit 成功路径下子表清空、父表删除;反向证明未部分提交(若部分提交, // projects 会在事务中途被删但 branches 残留 —— 通过重新 purge 验证幂等空操作)。 let (projects, tasks, _releases, branches) = setup().await; projects.insert(project("p1")).await.unwrap(); tasks.insert(task("t1", "p1")).await.unwrap(); branches.insert(branch("b1", "p1")).await.unwrap(); assert!(projects.purge_with_descendants("p1").await.unwrap()); // 事务已提交:父表与全部子表一致清空(不会出现父删子留) assert!(projects.get_by_id("p1").await.unwrap().is_none()); assert!(tasks.get_by_id("t1").await.unwrap().is_none()); assert!(branches.get_by_id("b1").await.unwrap().is_none()); // 再次 purge 已不存在的 id → false,且无副作用 assert!(!projects.purge_with_descendants("p1").await.unwrap()); } #[tokio::test] async fn purge_with_descendants_cascades_v29_v35_new_tables() { // G1.2:purge 级联补全 V29-V35 新增表(project_modules/module_dependencies/ // project_events/project_services/task_links)+ 经 workflow_executions 间接到项目的 // node_executions。此前 purge 只删 branches/releases/tasks/projects 四表,带 // 模块/事件/服务的工程在 PRAGMA foreign_keys=ON(无 ON DELETE CASCADE)下必 FK // 违例回滚(latent bug)。本测试断言全部关联子表按 FK 拓扑 最深子表→父 级联清空。 let db = Database::open_in_memory().await.expect("open_in_memory"); let projects = ProjectRepo::new(&db); let tasks = TaskRepo::new(&db); let releases = ReleaseRepo::new(&db); let branches = BranchRepo::new(&db); let workflows = WorkflowRepo::new(&db); let node_execs = NodeExecutionRepo::new(&db); let events = ProjectEventRepo::new(&db); let services = ProjectServiceRepo::new(&db); let modules = ProjectModuleRepo::new(&db); let deps = ModuleDependencyRepo::new(&db); let links = TaskLinkRepo::new(&db); // p1 全谱系子记录(V1 四表 + V29-V35 新表) projects.insert(project("p1")).await.unwrap(); tasks.insert(task("t1", "p1")).await.unwrap(); tasks.insert(task("t2", "p1")).await.unwrap(); releases.insert(release("r1", "p1")).await.unwrap(); branches.insert(branch("b1", "p1")).await.unwrap(); workflows .insert(WorkflowRecord { id: "w1".into(), name: "wf".into(), dag_json: "{}".into(), status: "pending".into(), triggered_by: None, project_id: Some("p1".into()), task_id: None, created_at: now_ts(), completed_at: None, }) .await .unwrap(); node_execs .insert(NodeExecutionRecord { id: "n1".into(), workflow_id: "w1".into(), node_id: "node1".into(), node_type: "ai".into(), status: "pending".into(), input_json: None, output_json: None, error_message: None, started_at: None, completed_at: None, }) .await .unwrap(); events .insert(ProjectEventRecord { id: "e1".into(), project_id: "p1".into(), event_type: "task_created".into(), entity_type: Some("task".into()), entity_id: Some("t1".into()), from_state: None, to_state: None, context_json: None, source: Some("human".into()), conversation_id: None, created_at: now_ts(), }) .await .unwrap(); services .insert_validated(ProjectServiceRecord { id: "s1".into(), project_id: "p1".into(), name: "db".into(), service_type: "mysql".into(), endpoint: Some("localhost:3306".into()), config_json: None, environment: "development".into(), remark: None, created_at: now_ts(), updated_at: now_ts(), }) .await .unwrap(); modules .insert(ProjectModuleRecord { id: "m1".into(), project_id: "p1".into(), name: "mod-1".into(), path: "C:/p1/mod1".into(), git_url: None, stack: None, auto_detected: false, sort_order: 0, created_at: now_ts(), updated_at: now_ts(), description: None, status: None, }) .await .unwrap(); modules .insert(ProjectModuleRecord { id: "m2".into(), project_id: "p1".into(), name: "mod-2".into(), path: "C:/p1/mod2".into(), git_url: None, stack: None, auto_detected: false, sort_order: 1, created_at: now_ts(), updated_at: now_ts(), description: None, status: None, }) .await .unwrap(); deps .insert(ModuleDependencyRecord { id: "d1".into(), project_id: "p1".into(), from_module_id: "m1".into(), to_module_id: "m2".into(), dep_type: "library".into(), label: None, created_at: now_ts(), }) .await .unwrap(); links .create_link("l1", "t1", "t2", "relates_to", None) .await .unwrap(); // 邻居项目 p2 只插一个 module,验证隔离(不波及) projects.insert(project("p2")).await.unwrap(); modules .insert(ProjectModuleRecord { id: "mp2".into(), project_id: "p2".into(), name: "mod-p2".into(), path: "C:/p2/mod".into(), git_url: None, stack: None, auto_detected: false, sort_order: 0, created_at: now_ts(), updated_at: now_ts(), description: None, status: None, }) .await .unwrap(); let affected = projects.purge_with_descendants("p1").await.unwrap(); assert!(affected, "应命中 p1"); // p1 全谱系子记录消失(最深子表→父逐层清空) assert!(projects.get_by_id("p1").await.unwrap().is_none()); assert!(tasks.get_by_id("t1").await.unwrap().is_none()); assert!(tasks.get_by_id("t2").await.unwrap().is_none()); assert!(releases.get_by_id("r1").await.unwrap().is_none()); assert!(branches.get_by_id("b1").await.unwrap().is_none()); assert!(workflows.get_by_id("w1").await.unwrap().is_none()); assert!(node_execs.get_by_id("n1").await.unwrap().is_none()); assert!(links.get_by_id("l1").await.unwrap().is_none(), "task_links 应级联清空"); assert!(modules.get_by_id("m1").await.unwrap().is_none()); assert!(modules.get_by_id("m2").await.unwrap().is_none()); assert!( deps.list_by_field("project_id", "p1").await.unwrap().is_empty(), "module_dependencies 应级联清空" ); assert!( events.get_by_project("p1", 200).await.unwrap().is_empty(), "project_events 应级联清空" ); assert!( services.list_by_project("p1").await.unwrap().is_empty(), "project_services 应级联清空" ); // 邻居 p2 完好(其 module 未被波及) assert!(projects.get_by_id("p2").await.unwrap().is_some()); assert!(modules.get_by_id("mp2").await.unwrap().is_some()); } // ============================================================ // 2. soft_delete / list_active / list_deleted / restore // ============================================================ #[tokio::test] async fn soft_delete_moves_project_out_of_active_into_deleted() { let (projects, _t, _r, _b) = setup().await; projects.insert(project("p1")).await.unwrap(); projects.insert(project("p2")).await.unwrap(); let ok = projects.soft_delete("p1").await.unwrap(); assert!(ok, "软删 p1 应命中"); let active = projects.list_active().await.unwrap(); let active_ids: Vec<_> = active.iter().map(|p| p.id.as_str()).collect(); assert!(!active_ids.contains(&"p1"), "p1 不应在 active"); assert!(active_ids.contains(&"p2"), "p2 仍在 active"); let deleted = projects.list_deleted().await.unwrap(); let deleted_ids: Vec<_> = deleted.iter().map(|p| p.id.as_str()).collect(); assert!(deleted_ids.contains(&"p1"), "p1 应在 deleted"); assert!(!deleted_ids.contains(&"p2"), "p2 不在 deleted"); } #[tokio::test] async fn restore_moves_project_back_into_active() { let (projects, _t, _r, _b) = setup().await; projects.insert(project("p1")).await.unwrap(); projects.soft_delete("p1").await.unwrap(); let ok = projects.restore("p1").await.unwrap(); assert!(ok, "恢复 p1 应命中"); let active_ids: Vec<_> = projects .list_active() .await .unwrap() .into_iter() .map(|p| p.id) .collect(); assert!(active_ids.contains(&"p1".to_string()), "p1 恢复后回到 active"); let deleted_ids: Vec<_> = projects .list_deleted() .await .unwrap() .into_iter() .map(|p| p.id) .collect(); assert!(!deleted_ids.contains(&"p1".to_string()), "p1 恢复后离开 deleted"); } #[tokio::test] async fn soft_delete_on_already_deleted_returns_false() { // WHERE deleted_at IS NULL 守卫:已删项二次软删不命中 let (projects, _t, _r, _b) = setup().await; projects.insert(project("p1")).await.unwrap(); assert!(projects.soft_delete("p1").await.unwrap()); // 第二次对已删项 → false(守卫生效) assert!( !projects.soft_delete("p1").await.unwrap(), "已删项二次软删应返回 false" ); } #[tokio::test] async fn soft_delete_unknown_id_returns_false() { let (projects, _t, _r, _b) = setup().await; assert!(!projects.soft_delete("ghost").await.unwrap()); } #[tokio::test] async fn restore_unknown_id_returns_false() { let (projects, _t, _r, _b) = setup().await; assert!(!projects.restore("ghost").await.unwrap()); } // ============================================================ // 3. allowed_columns_for — 按表隔离 // ============================================================ #[tokio::test] async fn update_field_rejects_cross_table_column_tasks_name() { // tasks 表白名单无 "name"(那是 projects/branches 的列)→ Err let (projects, tasks, _r, _b) = setup().await; projects.insert(project("p1")).await.unwrap(); tasks.insert(task("t1", "p1")).await.unwrap(); let res = tasks.update_field("t1", "name", "x").await; assert!(res.is_err(), "tasks 表无 name 列,应被白名单拒绝"); // 对照:tasks 的合法列 title 通过 let ok = tasks.update_field("t1", "title", "renamed").await.unwrap(); assert!(ok); let rec = tasks.get_by_id("t1").await.unwrap().unwrap(); assert_eq!(rec.title, "renamed"); } #[tokio::test] async fn update_field_rejects_tasks_status() { // D-260616-04 status 收口:status 已从 tasks 白名单移除,所有 status 改动须走 // advance_status_atomic(CAS SQL 独立路径,不经通用 update_field)。update_field // 改 status 应在白名单阶段被拒(防 update_task IPC / AI 工具旁路绕过状态机与 review_rounds 累加)。 let (projects, tasks, _r, _b) = setup().await; projects.insert(project("p1")).await.unwrap(); tasks.insert(task("t1", "p1")).await.unwrap(); let res = tasks.update_field("t1", "status", "done").await; assert!( res.is_err(), "status 不在 tasks 白名单,应被拒(改走 advance_status_atomic)" ); // 对照:status 未被改写,仍为初始值(task fixture 的初始 status) let rec = tasks.get_by_id("t1").await.unwrap().unwrap(); assert_ne!(rec.status.as_str(), "done", "白名单拒绝后 status 不应被改写"); } #[tokio::test] async fn update_field_rejects_projects_deleted_at() { // deleted_at 是专用路径列(soft_delete/restore),不进通用白名单 → 拒绝 let (projects, _t, _r, _b) = setup().await; projects.insert(project("p1")).await.unwrap(); let res = projects.update_field("p1", "deleted_at", "123").await; assert!( res.is_err(), "deleted_at 不在 projects 白名单,应被拒(专用路径才能写)" ); }