package repo /* DeleteSession 的判据(2026-10-03)。 # 这个判据要挡住的具体事故 **① 漏删从属表(最要紧)** 只 `DELETE FROM sessions` 的实现也能返回 200,但 mails 还在库里—— 用户在界面上看不到那条对话,那些邮件却还在,且可能被搜索/统计命中。 所以判据不仅断言「会话没了」,还断言**每一张引用表都空了**。 **② `mails.parent_mail_id` 自引用** schema 里它是 `REFERENCES mails(mail_id)`,**没有 ON DELETE**。 ★ 这条我一开始判断错了,判据也跟着错:我以为「有回复链时删除会撞约束」, 于是专门写了一格 `TestDeleteSessionWithReplyChain`。实测(变异验证时才发现): `DELETE FROM mails WHERE session_id = X` **同语句内删掉父子是合法的**, SQLite 不报错 —— `NO ACTION` 只在删除后**仍有行**引用被删行时才拦。 ⇒ 代码里那步「先置空 parent」是**防御性冗余**,不是必需。 ⇒ 判据也据此改了措辞:它现在测的是「有回复链时也要删得干净」 (真正的价值在**残留检查**,不在「会不会报错」)。 ⇒ 变异验证也据此改了:撤掉那步**不会**让判据转红,这是正确行为; 判据不该断言一个 SQLite 并不提供的保证。 **③ 删两遍要能与「删对」区分** 幂等返回 200 会让「重试」和「成功」不可区分。所以不存在必须报 ErrSessionNotFound,由 handler 映射成 404。 **④ 事务性** 任何一步失败必须整体回滚,不能留半删状态 —— 半删比不删更糟: 会话没了但邮件还在,而用户以为已经删干净了。 */ import ( "context" "database/sql" "errors" "testing" "github.com/agentmail/gateway/internal/db" ) func setupDeleteDB(t *testing.T) { t.Helper() db.Close() if err := db.Connect(context.Background(), "sqlite://"+t.TempDir()+"/del.db"); err != nil { t.Fatalf("连接测试库: %v", err) } if err := db.Migrate(context.Background()); err != nil { t.Fatalf("迁移测试库: %v", err) } t.Cleanup(db.Close) } // seedDeleteSession 造一条会话,返回 session_id。 func seedDeleteSession(t *testing.T, alias string) string { t.Helper() var id string err := db.DB.QueryRowContext(context.Background(), `INSERT INTO sessions (session_alias, subject, status, workspace, from_agent) VALUES ($1, '测试会话', 'active', '/tmp', 'pi') RETURNING session_id`, alias).Scan(&id) if err != nil { t.Fatalf("建会话: %v", err) } return id } // seedMail 造一封邮件;parent 非空时挂到另一封下(造回复链)。 func seedMail(t *testing.T, sid, subject, parent string) string { t.Helper() var id string var p any if parent != "" { p = parent } err := db.DB.QueryRowContext(context.Background(), `INSERT INTO mails (session_id, from_name, to_workspace, to_name, subject, body, status, parent_mail_id) VALUES ($1,'pi','/tmp','dsh',$2,'x','unread',$3) RETURNING mail_id`, sid, subject, p).Scan(&id) if err != nil { t.Fatalf("建邮件 %s: %v", subject, err) } return id } func countRows(t *testing.T, table, where string, args ...any) int { t.Helper() q := `SELECT COUNT(*) FROM ` + table + ` WHERE ` + where var n int if err := db.DB.QueryRowContext(context.Background(), q, args...).Scan(&n); err != nil { t.Fatalf("统计 %s: %v", table, err) } return n } // ★ 主判据:一张引用表都不能漏。 func TestDeleteSessionRemovesAllDependents(t *testing.T) { setupDeleteDB(t) sid := seedDeleteSession(t, "s1") parent := seedMail(t, sid, "父邮件", "") child := seedMail(t, sid, "回复邮件", parent) grand := seedMail(t, sid, "再回复", child) // 造齐从属数据。★ 列名一律照真实 schema 写 —— // 初版凭印象写了 attachments(size) 与 permission_requests(requested_mode), // 两个都不存在,于是造数据就 SQL error —— 判据自己先崩了,什么也没测到。 if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256) VALUES ($1,'pi','a.txt','text/plain',3,'abc')`, parent); err != nil { t.Fatalf("造附件: %v", err) } if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO mail_reads (mail_id, reader_name) VALUES ($1,'dsh')`, parent); err != nil { t.Fatalf("造已读: %v", err) } if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO relayed_mails (agent_name, relay_key, mail_id, kind) VALUES ('pi', $1, $2, 'human')`, "rk-"+parent, parent); err != nil { t.Fatalf("造转发记录: %v", err) } if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, kind, multi_select) VALUES ($1,$2,'dsh','同意吗','["full"]','tool',0)`, parent, sid); err != nil { t.Fatalf("造权限请求: %v", err) } if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO session_agent_locks (session_id, locked_at, until_at, hops, reason) VALUES ($1, datetime('now'), datetime('now','+1 hour'), 0, 'test')`, sid); err != nil { t.Fatalf("造会话锁: %v", err) } res, err := DeleteSession(context.Background(), sid) if err != nil { t.Fatalf("删除失败: %v", err) } // 会话与每一张引用表都必须为空 for _, c := range []struct { table, where string args []any }{ {"sessions", "session_id = $1", []any{sid}}, {"mails", "session_id = $1", []any{sid}}, {"attachments", "mail_id = $1", []any{parent}}, {"mail_reads", "mail_id = $1", []any{parent}}, {"relayed_mails", "mail_id = $1", []any{parent}}, {"permission_requests", "session_id = $1", []any{sid}}, {"session_agent_locks", "session_id = $1", []any{sid}}, } { if n := countRows(t, c.table, c.where, c.args...); n != 0 { t.Errorf("★ %s 仍有 %d 行 ⇒ 删会话漏了从属表", c.table, n) } } // 计数要如实反映(判据要能看出「删了但没删全」) if res.Mails != 3 { t.Errorf("报告删了 %d 封邮件,实际 3 封", res.Mails) } if res.Attachments != 1 || res.MailReads != 1 || res.Related != 1 || res.Permissions != 1 || res.Locks != 1 { t.Errorf("从属计数不符:%+v", res) } _ = grand // 三级回复链:父←子←孙,用来压自引用约束 } // ★ 有子回复链时也必须删得掉 —— 这是 parent_mail_id 自引用约束的正面测。 func TestDeleteSessionWithReplyChain(t *testing.T) { setupDeleteDB(t) sid := seedDeleteSession(t, "s2") a := seedMail(t, sid, "A", "") b := seedMail(t, sid, "B", a) seedMail(t, sid, "C", b) // 这格真正测的是「有回复链时也删得干净」,不是「会不会报约束错误」—— // 实测同语句内删父子合法,所以删掉那步 parent 置空也不会失败(见文件头)。 if _, err := DeleteSession(context.Background(), sid); err != nil { t.Fatalf("有回复链时删除失败: %v", err) } if n := countRows(t, "mails", "session_id = $1", sid); n != 0 { t.Errorf("★ 回复链残留 %d 封邮件", n) } } // ★ 删不存在的会话必须报 NotFound(不能静默成功)。 func TestDeleteSessionNotFound(t *testing.T) { setupDeleteDB(t) seedDeleteSession(t, "s3") _, err := DeleteSession(context.Background(), "00000000-0000-0000-0000-000000000000") if !errors.Is(err, ErrSessionNotFound) { t.Errorf("★ 应返回 ErrSessionNotFound(handler 据此回 404),实际 %v", err) } } // ★ 删除不得影响别的会话 —— 按 session_id 精确删,不能整表清。 func TestDeleteSessionDoesNotTouchOthers(t *testing.T) { setupDeleteDB(t) doomed := seedDeleteSession(t, "s4") keep := seedDeleteSession(t, "s5") keepMail := seedMail(t, keep, "要保留的", "") if _, err := DeleteSession(context.Background(), doomed); err != nil { t.Fatalf("删除失败: %v", err) } if n := countRows(t, "sessions", "session_id = $1", keep); n != 1 { t.Error("★ 别的会话被误删") } if n := countRows(t, "mails", "mail_id = $1", keepMail); n != 1 { t.Error("★ 别的会话的邮件被误删") } } // ★ 事务性:中途失败必须整体回滚,不能留半删状态。 func TestDeleteSessionIsAtomic(t *testing.T) { setupDeleteDB(t) sid := seedDeleteSession(t, "s6") mail := seedMail(t, sid, "唯一", "") // 让 mails 的删除必然失败:先给 parent_mail_id 建一个**指向别处** // 的外键不现实,改为用触发器注入失败。 if _, err := db.DB.ExecContext(context.Background(), ` CREATE TRIGGER boom BEFORE DELETE ON mails BEGIN SELECT RAISE(ABORT, 'boom'); END;`); err != nil { t.Skipf("触发器不可用: %v", err) } if _, err := DeleteSession(context.Background(), sid); err == nil { t.Fatal("★ 注入失败后 DeleteSession 居然返回成功") } // 关键:会话必须还在,且邮件还在 —— 即整体回滚 if n := countRows(t, "sessions", "session_id = $1", sid); n != 1 { t.Error("★ 失败后会话被删了 ⇒ 没有整体回滚(半删比不删更糟)") } if n := countRows(t, "mails", "mail_id = $1", mail); n != 1 { t.Error("★ 失败后邮件被删了 ⇒ 没有整体回滚") } } var _ = sql.ErrNoRows