- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod - AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation - 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n - 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
483 lines
17 KiB
Rust
483 lines
17 KiB
Rust
//! 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,
|
|
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 白名单,应被拒(专用路径才能写)"
|
|
);
|
|
}
|