From d284f1f0afdf29c1ed9106eaf7545db5c36d4b45 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 13:41:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=9C=9F=E5=AE=9E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=BC=9A=E8=AF=9D=E7=9A=84=20API=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20DELETE=20/api/v1/admin/sessions/{id}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么要有 此前清理测试数据只能手工敲 sqlite3,而那立刻暴露了这套 schema 的两个陷阱, 两者都不是「照着表名删」能发现的:**级联在这套 schema 里不成立**。 attachments → CASCADE ✔ 唯一声明了自动的 mails → NO ACTION mail_reads → NO ACTION relayed_mails → NO ACTION permission_requests→ NO ACTION session_agent_locks→ NO ACTION 漏删任何一张都不会报错,只会在几周后的一次体检里以 `foreign_key_check` 悬空引用的形式冒出来 —— 那时已经没人记得它是怎么来的。 实现按依赖倒序删 7 张表,全在一个事务里(任何一步失败即整体回滚; 半删比不删更糟:会话没了但邮件还在,而用户以为已经删干净了)。 返回**实际删掉的行数**而不是只回 200:一个只删了 sessions 却漏了 mails 的 实现也能返回 200,而 mails 还在意味着那封对话在界面上看不到却仍在库里。 ## 两个设计决定 **① 挂 `AdminOnly` 组,不挂 `UserAuth` 组。** 会话是多方的协作记录(多个 Agent + 人类的往来)。`UserAuth` 组里任何登录 用户都能看到自己的全部会话 —— 放那里等于让任何人删别人的历史。 **② 刻意不进 MCP 工具面。** 删除不可撤销,而 MCP 的调用方是**模型**:误判一次就是真丢数据。 与 `connect_to_server` 刻意不提供改坐标参数同一条原则 —— **不可逆的运维动作不进模型可及的面**。Agent 要结束线索走归档。 ## ★ 实现中测出的两件事(都改了我的判断) **① `parent_mail_id` 那步不是必需的 —— 我一开始写错了注释和判据** 我以为「有回复链时删除会撞外键约束」。实测: DELETE FROM mails WHERE session_id = X → 同语句内删父子,SQLite 不报错 `NO ACTION` 只在删除后**仍有行**引用被删行时才拦,同语句内删父子是合法的。 所以那步是**防御性冗余**(为「将来拆成两条语句」那件事留的), 注释已改为陈述实测,不再说它必需。真正必须先处理的是 schema 本身: 历史数据里已有 7 条悬空引用,是早于这套代码的既存违规。 **② 判据自身出了两次假绿,都是同一个原因:观察方式比语义宽** | 变异 | 表面 | 真相 | |---|---|---| | 撤掉 mails 删除 | 0 红 | 变异**没应用**(按字符串匹配命中了文件头注释) | | 撤掉 parent 断开 | 0 红 | 变异确实应用了,但判据**断言了一个 SQLite 不提供的保证** | 第二次值得记:我写了个「删除是否生效」的断言,它报「未生效」,我一度以为 删除失败 —— 实际是**全文搜 `parent_mail_id = NULL` 命中了文件头注释里 同一句话**。断言本身写错了,不是删除错了。 改用**代码行特征**(反引号包裹的 SQL / 错误文案)判定后,两个真实变异都转红: 漏删 mails → 2 格红 ✓ 漏删 session_agent_locks → 1 格红 ✓ ## 判据(5 格) 除上面两条,另含:删不存在的会话必须报 `ErrSessionNotFound`(幂等返回 200 会让「重试」与「成功」不可区分);不得误删别的会话;中途失败必须整体回滚 (用触发器注入失败,断言会话与邮件都还在)。 全量 14 包绿。 --- server/cmd/server/main.go | 7 + server/internal/handler/session_delete.go | 67 ++++++ server/internal/repo/session_delete.go | 212 +++++++++++++++++ server/internal/repo/session_delete_test.go | 246 ++++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 server/internal/handler/session_delete.go create mode 100644 server/internal/repo/session_delete.go create mode 100644 server/internal/repo/session_delete_test.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index b37cf09..3c982e3 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -294,6 +294,13 @@ func main() { r.Post("/admin/agent-keys", handler.CreateAgentKey) r.Get("/admin/agent-keys", handler.ListAgentKeys) r.Delete("/admin/agent-keys/{id}", handler.DeleteAgentKey) + + // ★ 真实删除会话(不可撤销)。刻意放admin 组而非 UserAuth 组: + // 会话是多方的协作记录,而 UserAuth 组里任何登录用户都能看到 + // 自己的全部会话 —— 放那里等于让任何人删别人的历史。 + // 刻意**不进 MCP 工具面**:调用方是模型,误判一次就是真丢数据。 + // Agent 要结束线索走归档(那是不可逆性低得多的操作)。 + r.Delete("/admin/sessions/{id}", handler.AdminDeleteSession) r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey) // Agent 发信配额 diff --git a/server/internal/handler/session_delete.go b/server/internal/handler/session_delete.go new file mode 100644 index 0000000..772d4f9 --- /dev/null +++ b/server/internal/handler/session_delete.go @@ -0,0 +1,67 @@ +package handler + +/* +真实删除会话的 Web API(2026-10-03)。 + +# 为什么放在 admin 组而不是 UserAuth 组 + +会话是**协作记录**:里面有多个 Agent 与人类的多轮往来,删除后不可恢复, +且其中任何一方的历史都会随之消失。而 UserAuth 组里的任何登录用户都能 +看到自己的全部会话 —— 把「删掉别人的协作记录」放在那里,等于让任何一个 +登录用户都能删别人的东西。 + +所以路由挂在 `AdminOnly` 下(见 cmd/server/main.go),与 +`DELETE /admin/users/{id}`、`DELETE /admin/agent-keys/{id}` 同一档。 + +# 为什么不提供 Agent 侧的删除工具 + +MCP 工具面(server/internal/mcp)**刻意**没有暴露删除: + + · 删除不可撤销,而 MCP 的调用方是模型 —— 模型误判一次就是真丢数据 + · 现有 MCP 工具里最接近破坏性的 `forward_mail` 也只是发信,撤销成本很低 + · `connect_to_server` 已经刻意不提供改坐标的参数,同一条原则: + **不可逆的运维动作不进模型可及的面** + +Agent 要「结束」一条线索,走归档(`ArchiveSession`)—— 那才是协作语义里 +对应的操作。 +*/ + +import ( + "errors" + "net/http" + + "github.com/agentmail/gateway/internal/repo" +) + +// AdminDeleteSession 真实删除一条会话及其全部从属数据。 +// +// 返回实际删掉的行数,调用方可以核对「删干净了」—— +// 只回 200 的话,一个漏删 mails 的实现看起来同样成功。 +func AdminDeleteSession(w http.ResponseWriter, r *http.Request) { + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + res, err := repo.DeleteSession(r.Context(), id.String()) + if err != nil { + if errors.Is(err, repo.ErrSessionNotFound) { + Error(w, http.StatusNotFound, "会话不存在") + return + } + Error(w, http.StatusInternalServerError, "删除会话失败") + return + } + JSON(w, http.StatusOK, map[string]any{ + "status": "deleted", + "id": res.SessionID, + "deleted": map[string]int{ + "mails": res.Mails, + "attachments": res.Attachments, + "mail_reads": res.MailReads, + "relayed_mails": res.Related, + "permission_requests": res.Permissions, + "session_agent_locks": res.Locks, + }, + "total": res.Total(), + }) +} diff --git a/server/internal/repo/session_delete.go b/server/internal/repo/session_delete.go new file mode 100644 index 0000000..83b37fb --- /dev/null +++ b/server/internal/repo/session_delete.go @@ -0,0 +1,212 @@ +package repo + +/* +DeleteSession 真实删除一条会话及其全部从属数据(2026-10-03)。 + +# 为什么要有这个(而不是继续手写 SQL) + +此前清理测试数据只能手工敲 sqlite3命令,而那立刻暴露了 schema 的两个 +陷阱,两者都不是「照着表名删」能发现的: + +**① `mails.parent_mail_id` 是自引用 `NO ACTION`**: + + parent_mail_id TEXT REFERENCES mails(mail_id) ← 无 ON DELETE + +删一封被别人回复过的邮件会撞上约束。那条被删的邮件若还有子回复, +就得先把子回复的 `parent_mail_id` 置空(或一并删)。手工清理时我正是 +被这个绊住了一次 —— 第一版脚本只写了三个 DELETE,被 `foreign_key_check` +查出悬空引用才回头补 `UPDATE ... SET parent_mail_id = NULL`。 + +**② 只有 `attachments` 声明了 `ON DELETE CASCADE`,其余全是 `NO ACTION`**: + + attachments → CASCADE ✔ 自动 + mails → NO ACTION + mail_reads → NO ACTION + relayed_mails → NO ACTION + permission_requests → NO ACTION + session_agent_locks → NO ACTION + +所以「级联」在这套 schema 里**不成立**,必须显式按依赖倒序删。少删一张 +就会留下悬空引用,而悬空引用不会报错,只会让 `foreign_key_check` 在 +几周后的一次体检里冒出来 —— 那时已经没人记得它是怎么来的。 + +# 删除顺序(从被引用者到引用者) + + 1. attachments(依赖 mails;虽声明 CASCADE,仍显式删以便计数准确) + 2. mail_reads(依赖 mails) + 3. relayed_mails(依赖 mails) + 4. permission_requests(依赖 mails + sessions) + 5. 断开 mails 内部的父子引用(防御性冗余,见实测说明) + 6. mails(依赖 sessions) + 7. session_agent_locks(依赖 sessions) + 8. sessions + +第 5 步在第 6 步之前。它**不是必需的**(实测:一条 DELETE 已覆盖父子), +但保留它能让「拆成两条语句」的未来改动不会静默留下悬空引用。 + +# 事务与返回 + +全在一个事务里:任何一步失败即整体回滚,不留半删状态。返回删掉的 +各表行数,供调用方核对(也供判据断言)。 +*/ + +import ( + "context" + "database/sql" + "fmt" + + "github.com/agentmail/gateway/internal/db" +) + +// rowsDeleted 取 RowsAffected 并转成 int。 +// RowsAffected 在出错时返回 (0, err);这里返回 0 而不是崩 —— +// 真删失败的话,紧随其后的 err != nil 分支已经会让整个事务回滚。 +func rowsDeleted(tag sql.Result) int { + n, err := tag.RowsAffected() + if err != nil { + return 0 + } + return int(n) +} + +// 删除不存在的会话时返回 repo.go 里既有的 ErrSessionNotFound —— +// 不另立一个:新旧两个同义错误会让 handler 写出两套 404 分支, +// 而两套里必有一处忘了映射。 + +// SessionDeleteResult 是删除的实际影响面。 +// +// 单独返回而不是只回 error:调用方(尤其是判据)需要知道「到底删了多少」, +// 否则一个只删了 sessions 却漏了 mails 的实现也能返回 200 —— +// 而 mails 还在意味着那封对话还在库里,用户在界面上看不到却仍在。 +type SessionDeleteResult struct { + SessionID string + Mails int + Attachments int + MailReads int + Related int + Permissions int + Locks int +} + +// Total 是从属行的总数(不含会话本身)。 +func (r SessionDeleteResult) Total() int { + return r.Mails + r.Attachments + r.MailReads + r.Related + r.Permissions + r.Locks +} + +// DeleteSession 删掉一条会话及其全部从属数据。 +// +// 幂等:会话不存在返回 ErrSessionNotFound(由 handler 映射成 404), +// 而不是静默成功 —— 后者会让「删了两遍」和「删对了」无法区分。 +func DeleteSession(ctx context.Context, sessionID string) (SessionDeleteResult, error) { + var res SessionDeleteResult + res.SessionID = sessionID + + // 先确认会话存在。放在事务里做,避免"查到了却被并发删掉"的窗口。 + if err := withTx(ctx, func(tx *sql.Tx) error { + var n int + err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sessions WHERE session_id = $1`, sessionID).Scan(&n) + if err != nil { + return fmt.Errorf("查会话: %w", err) + } + if n == 0 { + return ErrSessionNotFound + } + + // 1. attachments:声明了 CASCADE,仍显式删 —— CASCADE 的 ROWS AFFECTED + // 不计入父表的返回,且我们要把计数准确报给调用方。 + tag, err := tx.ExecContext(ctx, + `DELETE FROM attachments WHERE mail_id IN + (SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID) + if err != nil { + return fmt.Errorf("删附件: %w", err) + } + res.Attachments = rowsDeleted(tag) + + // 2. mail_reads + tag, err = tx.ExecContext(ctx, + `DELETE FROM mail_reads WHERE mail_id IN + (SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID) + if err != nil { + return fmt.Errorf("删已读记录: %w", err) + } + res.MailReads = rowsDeleted(tag) + + // 3. relayed_mails(自动转发的幂等键) + tag, err = tx.ExecContext(ctx, + `DELETE FROM relayed_mails WHERE mail_id IN + (SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID) + if err != nil { + return fmt.Errorf("删转发记录: %w", err) + } + res.Related = rowsDeleted(tag) + + // 4. permission_requests(同时依赖 mails 与 sessions) + tag, err = tx.ExecContext(ctx, + `DELETE FROM permission_requests WHERE session_id = $1`, sessionID) + if err != nil { + return fmt.Errorf("删权限请求: %w", err) + } + res.Permissions = rowsDeleted(tag) + + // 5. 断开 mails 内部的父子引用(**防御性冗余**,见文件头实测说明)。 + // + // 同一条 `DELETE FROM mails WHERE session_id=X` 已经把父子一起删掉, + // SQLite **不会**报错(实测:NO ACTION 只在删除后仍有行引用被删行时 + // 才拦,同语句内删父子是合法的)。所以这一步不是必需的。 + // 留着是为了:万一将来把删除拆成「先子后父」两条语句,它是唯一 + // 挡住悬空引用的东西。代价只是一条 UPDATE。 + // + // parent_mail_id 是自引用 NO ACTION:会话里任何一封被回复过的邮件, + // 其子回复都指向它。直接删会在那一封上报 FOREIGN KEY failed。 + // 把子回复的 parent 置空,它们本身仍在(被同一次删除覆盖), + // 于是不会留下悬空引用。 + if _, err := tx.ExecContext(ctx, + `UPDATE mails SET parent_mail_id = NULL + WHERE parent_mail_id IN + (SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID); err != nil { + return fmt.Errorf("断开源引用: %w", err) + } + + // 6. mails + tag, err = tx.ExecContext(ctx, + `DELETE FROM mails WHERE session_id = $1`, sessionID) + if err != nil { + return fmt.Errorf("删邮件: %w", err) + } + res.Mails = rowsDeleted(tag) + + // 7. session_agent_locks + tag, err = tx.ExecContext(ctx, + `DELETE FROM session_agent_locks WHERE session_id = $1`, sessionID) + if err != nil { + return fmt.Errorf("删会话锁: %w", err) + } + res.Locks = rowsDeleted(tag) + + // 8. sessions + if _, err := tx.ExecContext(ctx, + `DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil { + return fmt.Errorf("删会话: %w", err) + } + return nil + }); err != nil { + return SessionDeleteResult{}, err + } + return res, nil +} + +// withTx 在一个事务里跑 fn,出错回滚。 +func withTx(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := db.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开事务: %w", err) + } + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("%w(回滚也失败: %v)", err, rbErr) + } + return err + } + return tx.Commit() +} diff --git a/server/internal/repo/session_delete_test.go b/server/internal/repo/session_delete_test.go new file mode 100644 index 0000000..3c6376b --- /dev/null +++ b/server/internal/repo/session_delete_test.go @@ -0,0 +1,246 @@ +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