Files
MailUI4Agents/server/internal/repo/session_status_invariant_test.go
JianFeeeee e78888b756 fix(归档): 两表判据分叉的成因收口 —— TouchSession 不再写 status,建邮件一律拒归档会话
pi 2026-09-28 裁定 §1/§2 认可「判据分叉」这个定性,§4 要求做 1+2,
并把 permission/request 点为第三个复活入口。本轮做 1+2,并补上第四个。

# 缺陷:归档后不可见,判据挂在两张表上

  unreadFor / readStateFor   判邮件行   (repo.go:unreadFor)
  ListInbox / UnreadWorkspaces 判会话行   (repo.go:ListInbox)

两边对同一条已归档线索给出不同答案,而每一边单独看都「是对的」。
分叉由 `TouchSession` 的 `SET status='active'` 与建邮件 INSERT 只写
邮件行共同造成 ⇒ 只要有一个写路径碰会话行而不碰邮件行,半活会话就能被造出来。

# 改法:让不变量由构造保证,而不是逐个入口堵

  · TouchSession 只剩 updated_at —— 它是全库唯一能解除归档的入口
  · EnsureSessionOpen 是 CreateMail / CreatePermissionMail / CreateDecisionMail
    的共同前置(集中一处,新增建邮件函数必须经过它)
  · ErrSessionArchived 与 ErrSessionNotFound 分列:调用方要能分开回话
  · resolveTarget 的 reply_to 分支恢复归档契约(此前绕过别名路径的 404)
  · permission/request 补 SessionOpenFor:存在 + 未归档 + 参与方
  · FindSessionByPlatformID 补 s.status(adopt 路径,pi 未列的第四个入口)

# 判据:写成不变量而不是单点

session_status_invariant_test.go:对任意 session_id,
sessions.status='archived' ⟹ 该会话全部邮件 archived。入口级回归单测仍在,
但它们是说明。已实测把 TouchSession 改回旧实现后该判据转红
(不是「改完就绿」的装饰)。

# 读侧清册

mail_status_readers_test.go 的清册仍为 repo.go=13 / thread.go=1 / migrate.go=4:
本轮新增的 5 处命中全在注释里(散文里拼了列名字面量),已改写措辞而不改数字
—— 让数字变化会给未来新增读取凭空送出 5 格余量,正是那张表要防的事。

# 遗留(pi 裁定本轮不做,已登记)

FindSessionByAddress 无 status 条件:补上会把重复归档从 200 变成 404,
属行为变更,不在 bugfix 里夹带。
2026-09-28 11:01:38 +08:00

230 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"errors"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
判据:`sessions.status` 与 `mails.status` **不得分叉**。
# 为什么不变量、单条路径都测不出来
「已归档 ⇒ 对所有人不可见」在读侧由两处判据保证,而它们挂在**不同的表**上:
unreadFor / readStateFor 判 `m.status` (repo.go:unreadFor)
ListInbox / UnreadWorkspaces 判 `s.status` (repo.go:ListInbox)
两边对同一条已归档线索给出**不同答案**,而每一边单独看都是"对的"
—— `unreadFor` 说"这封是归档所以不算未读",`ListInbox` 说"这条会话没归档所以列出来"。
分叉是怎么被造出来的:`TouchSession` 写的是 `sessions.status`
(`UPDATE ... SET status='active'`),而邮件那条 INSERT 写的是 `mails.status`。
**任何只碰前者而不碰后者的写路径,都会造出半活会话。**
所以判据不能写成"reply_to 应当 404"—— 那是**单点**,今天有三个入口
(reply_to / permission-request / adopt),明天可能有第四个。
真正堵住洞的是这一条:
对任意 session_id:sessions.status = 'archived' ⟹ 该会话全部 mails.status = 'archived'
它是**不可分叉**的形状:只要 CreateMail 系(唯一会写出 mails.status <>
'archived' 的入口)拒绝归档会话,TouchSession 系(唯一会解除归档的入口)
不再碰 status,那么任何**组合**调用都不可能分叉 —— 判据不必枚举入口。
入口级的回归仍单测(TestArchivedSessionRefuses*),但它们是**说明**,
这一条才是防线。
# 参照的坏结论
2026-09-28 探针期间 pi 的 `压测-限流-17` 得出「16/16 归档会话零回信、
4/4 活会话有回信」—— 那 4 条"活"会话正是被 TouchSession 复活的,
把「从未归档」和「归档后被复活」混成了一类。判据若只测「归档后回信会怎样」,
就抓不到这类混用;**分叉**是它们的共同签名。
*/
// sessionStatusOf 读一条会话的 sessions.status。
func sessionStatusOf(t *testing.T, sid uuid.UUID) string {
t.Helper()
var s string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT status FROM sessions WHERE session_id = $1`, sid).Scan(&s); err != nil {
t.Fatal(err)
}
return s
}
// assertNoDivergence 是本文件所有判据的公共末尾:扫全库确认无半活会话。
//
// 扫全库而不是只看刚操作那条:分叉的读数要跨会话聚合才看得见
// (`ListInbox` 按 s.status 过滤、`unreadFor` 按 m.status 过滤,同一会话的
// 同一封邮件在两条路径上分别"在"和"不在")。
func assertNoDivergence(t *testing.T) {
t.Helper()
rows, err := db.DB.QueryContext(context.Background(), `
SELECT s.session_id, s.session_alias, s.status, m.mail_id, m.status
FROM sessions s JOIN mails m ON m.session_id = s.session_id
WHERE s.status = 'archived' AND m.status <> 'archived'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if rows.Next() {
var sid, alias, sstat, mid, mstat string
if err := rows.Scan(&sid, &alias, &sstat, &mid, &mstat); err != nil {
t.Fatal(err)
}
t.Fatalf("★ 半活会话:session %s(alias=%q) status=%s,但邮件 %s 的 mails.status=%s\n"+
" sessions.status='archived' 的会话里不该有非 archived 的邮件 ——\n"+
" ListInbox 按 s.status 放它出来、unreadFor 按 m.status 继续藏着,同一封两个答案。\n"+
" 查这两条写路径:TouchSession 是否又写了 status?CreateMail 系是否又漏了守卫?",
sid, alias, sstat, mid, mstat)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
}
// seedArchivedSession 建一条含一封邮件的会话并把它整条归档,返回 (sessionID, mailID)。
func seedArchivedSession(t *testing.T) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
id := seedMailTo(t, "bob", "")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := ArchiveSession(ctx, sid); err != nil {
t.Fatal(err)
}
return sid, id
}
// TouchSession 不得解除归档 —— 这是半活会话的**唯一**成因。
func TestTouchSessionDoesNotUnarchive(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
if err := TouchSession(ctx, sid); err != nil {
t.Fatal(err)
}
if got := sessionStatusOf(t, sid); got != "archived" {
t.Fatalf("★ TouchSession 把已归档会话改成了 %q —— 它只该刷 updated_at。\n"+
" 这一格是全库唯一能写 sessions.status 的地方(除 ArchiveSession),\n"+
" 它一旦能写 status',reply_to / permission / adopt 三个入口就都能复活归档会话。", got)
}
assertNoDivergence(t)
}
// 归档后不能有**任何**新邮件落进去 —— 落进去的那封必是 mails.status='unread'
// (DEFAULT),于是归档线索长出未读信,而 ListInbox 又因为 s.status 放它出来。
func TestArchivedSessionRefusesNewMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
if _, err := CreateMail(ctx, sid, nil, "sender", "", "bob", "", "s", "b", nil); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreateMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
if _, err := CreatePermissionMail(ctx, sid, "sender", "bob", "q", "b", []string{"同意"}, "permission", false); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreatePermissionMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
if _, err := CreateDecisionMail(ctx, sid, uuid.Nil, "bob", "sender", "同意", ""); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreateDecisionMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
assertNoDivergence(t)
}
// 守卫必须是 ErrSessionArchived 而不是 ErrSessionNotFound:调用方要能分开回话。
// 「已归档」可以 `.new` 另起一条;「不存在」要先确认地址写没写错。
func TestArchivedIsDistinctFromMissing(t *testing.T) {
setupTestDB(t)
sid, _ := seedArchivedSession(t)
if err := EnsureSessionOpen(context.Background(), sid); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("已归档会话的读数是 %v,期望 ErrSessionArchived(与 NotFound 分列)", err)
}
if err := EnsureSessionOpen(context.Background(), uuid.New()); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("不存在会话的读数是 %v,期望 ErrSessionNotFound", err)
}
}
// SessionOpenFor 补上写路径此前一个都不查的**参与方**判据。
func TestSessionOpenForRejectsForeignSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
// "bob" 是收件人,参与过 → 但会话已归档,先被归档这一格挡住
if err := SessionOpenFor(ctx, "bob", sid); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("参与者对已归档会话的读数是 %v,期望 ErrSessionArchived", err)
}
// 解除归档后(模拟人先归档又改主意),参与者应通过
if _, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET status='active' WHERE session_id = $1`, sid); err != nil {
t.Fatal(err)
}
if err := SessionOpenFor(ctx, "bob", sid); err != nil {
t.Fatalf("参与者 bob 应可向未归档会话发信,读数 %v", err)
}
// 没参与过的第三方:这条线是他没参与的线索
if err := SessionOpenFor(ctx, "stranger", sid); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("未参与者 stranger 的读数是 %v,期望 ErrSessionNotFound(携带别人的 id 不得注入邮件)", err)
}
// from_agent 也是一方:它发起的线索自己当然能继续
if err := SessionOpenFor(ctx, "sender", sid); err != nil {
t.Fatalf("会话发起方 sender 应可发信,读数 %v", err)
}
}
// 归档会话在读侧仍然对所有人不可见 —— 与 readstate_test.go 的既有判据同源,
// 这里重跑一遍是为了让「写侧堵死」不会悄悄改掉「读侧仍成立」的前提。
func TestArchivedStaysInvisibleInBothTables(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, id := seedArchivedSession(t)
if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("已归档会话的邮件不该计入未读(mails.status 判据),实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
t.Fatal("已归档会话的邮件不该出现在 unread 收件箱")
}
mails, err := ListInbox(ctx, "bob", "all", "", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
t.Fatal("已归档会话的邮件不该出现在收件箱(all 也不该有)—— sessions.status 判据被绕过了")
}
}
assertNoDivergence(t)
}
// 归档**之后**再走一遍 TouchSession(投递路径的唯一副作用)也不该分叉。
// 单测按「入口」写,这条按「时序」写:先归档、后投递,是线上真实发生的顺序。
func TestArchiveAfterDeliveryKeepsTablesTogether(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
// 归档后再投递三次:别名命中、TouchSession、CreateMail 三步都得被拒
for i := 0; i < 3; i++ {
_ = TouchSession(ctx, sid)
_, _ = CreateMail(ctx, sid, nil, "sender", "", "bob", "", "s", "b", nil)
}
assertNoDivergence(t)
if got := sessionStatusOf(t, sid); got != "archived" {
t.Fatalf("重复投递把会话状态推成了 %q,期望仍是 archived", got)
}
}