From e78888b756af0e1ab2aca706462bdd6fbb29037b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 11:01:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=BD=92=E6=A1=A3):=20=E4=B8=A4=E8=A1=A8?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E5=88=86=E5=8F=89=E7=9A=84=E6=88=90=E5=9B=A0?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20=E2=80=94=E2=80=94=20TouchSession=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=20status=EF=BC=8C=E5=BB=BA=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E4=B8=80=E5=BE=8B=E6=8B=92=E5=BD=92=E6=A1=A3=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 里夹带。 --- server/internal/handler/mail.go | 11 + server/internal/handler/permission.go | 28 +++ server/internal/repo/platform_sessions.go | 1 + server/internal/repo/repo.go | 122 +++++++++- .../repo/session_status_invariant_test.go | 229 ++++++++++++++++++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 server/internal/repo/session_status_invariant_test.go diff --git a/server/internal/handler/mail.go b/server/internal/handler/mail.go index b11d89c..b7bef49 100644 --- a/server/internal/handler/mail.go +++ b/server/internal/handler/mail.go @@ -82,6 +82,17 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub if err != nil { return uuid.Nil, nil, false, errNotFound("Parent mail not found") } + // 归档契约在这条路径上同样成立:别名寻址回 404(FindNamedSessionFor + // 带 s.status <> 'archived'),reply_to 是**绕过它的那条路**。 + // + // 原先这里直接 TouchSession —— 而它写 `status='active'`,于是 + // 「已归档 + reply_to」= 会话被复活而邮件行留在 archived ⇒ 半活会话 + // (ListInbox 按会话状态把它放回来、unreadFor 按邮件行继续藏)。 + // 判据见 repo.TouchSession 与 session_status_invariant_test.go。 + if err := repo.EnsureSessionOpen(r.Context(), mail.SessionID); err != nil { + return uuid.Nil, nil, false, errNotFound( + "无法送达:被回复的邮件属于一条已归档的会话。归档是单向的,请用 .new 另起一条") + } repo.TouchSession(r.Context(), mail.SessionID) return mail.SessionID, &replyID, false, nil } diff --git a/server/internal/handler/permission.go b/server/internal/handler/permission.go index a0ebe8d..bb9f064 100644 --- a/server/internal/handler/permission.go +++ b/server/internal/handler/permission.go @@ -128,6 +128,22 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusBadRequest, "Invalid session_id") return } + // 三个判据:存在 / 未归档 / 发起方确实是这条线索的一方。 + // + // 这一格原先**一个都不查**(只 parse UUID 就 TouchSession 然后建邮件), + // 而 session_id 完全来自请求体 ⇒ 携带别人的 id 就能往那条线索里投一封 + // 权限询问。原先那格唯一挡住的是 status:TouchSession 会把归档会话 + // 改成 active,于是"往已归档线索里发信"也一并做到了(两表分叉的成因)。 + // 判据见 repo.SessionOpenFor 的注释。 + if err := repo.SessionOpenFor(r.Context(), agentName, id); err != nil { + switch { + case errors.Is(err, repo.ErrSessionArchived): + Error(w, http.StatusNotFound, "该会话已归档,不再接受权限询问;请新建会话") + default: + Error(w, http.StatusNotFound, "会话不存在,或你未参与该会话") + } + return + } sessionID = id repo.TouchSession(r.Context(), sessionID) } else { @@ -365,6 +381,18 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) { return } + // 归档检查必须在 DecidePermission **之前**:否则决策已落库、回执邮件却因为 + // 会话已归档建不出来 —— 发起方那边永远等不到回执(任务挂死), + // 而人在界面上看到的却是"已处理"。宁可不决策,让人看见失败。 + if err := repo.SessionOpenFor(r.Context(), user.Username, perm.SessionID); err != nil { + if errors.Is(err, repo.ErrSessionArchived) { + Error(w, http.StatusNotFound, "该会话已归档,无法回执决策") + } else { + Error(w, http.StatusNotFound, "会话不存在") + } + return + } + // 决策人 = 当前登录用户(已读只记到他名下,不影响这条线索上其他收件人) if _, err := repo.DecidePermission(r.Context(), mailID, user.Username, req.Decision); err != nil { Error(w, http.StatusInternalServerError, "Failed to decide permission") diff --git a/server/internal/repo/platform_sessions.go b/server/internal/repo/platform_sessions.go index a6d60bb..58c54d1 100644 --- a/server/internal/repo/platform_sessions.go +++ b/server/internal/repo/platform_sessions.go @@ -388,6 +388,7 @@ func FindSessionByPlatformID(ctx context.Context, agentName, platformID string) SELECT s.session_id FROM sessions s WHERE s.platform_id = $1 + AND s.status <> 'archived' AND EXISTS ( SELECT 1 FROM mails m WHERE m.session_id = s.session_id diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index d0e7755..2a7f1db 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -371,12 +371,89 @@ func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error) return &s, nil } +// TouchSession 刷一条会话的 updated_at("这条线索刚刚有新活动")。 +// +// ★★ 它**只**更新 updated_at,**绝不碰 status**(2026-09-28 bugfix)。 +// +// 原实现是 `SET updated_at = NOW(), status = 'active'`。那一行 `status='active'` +// 让**每一个**调用点都成了"复活入口":6 个调用点里 5 个的 id 都是服务端刚查出来的 +// (收件人确实参与过的那条会话),只有 reply_to 与 permission/request 是从请求里拿的 +// id。于是"归档后不可见"被这样绕过: +// +// sessions.status: archived → active(只被这一列改) +// mails 那张表: archived(原地不动)⇒ **半活会话** +// +// 半活会话在库里有两处可观察的裂口: +// - `ListInbox` 整体按 `s.status <> 'archived'` 过滤 ⇒ 归档邮件**重新出现在列表里**, +// 只是标着"已归档"(判据读会话表); +// - `unreadFor` 判的是邮件行上的 `<> 'archived'` ⇒ 收件箱 unread 一封都不给 +// (判据读邮件表)。同一个"归档"在两张表上给出**两个不同答案**。 +// +// 而"不可见"的判据本来就不该由**任何**单点写入来恢复 —— 归档是全局的、 +// 单向的(前端只有归档入口,没有恢复入口)。所以正确的形状不是"在 reply_to 里 +// 拦一道"(那只是把一个入口堵上),而是让这个函数**没有能力**去改 status。 +// 判据见 session_status_invariant_test.go:不变量写在**两个写入口**(CreateMail 系 +// 与 ArchiveSession)上,而不是写成"某条路径返回 404"。 func TouchSession(ctx context.Context, id uuid.UUID) error { _, err := db.DB.ExecContext(ctx, - `UPDATE sessions SET updated_at = NOW(), status = 'active' WHERE session_id = $1`, id) + `UPDATE sessions SET updated_at = NOW() WHERE session_id = $1`, id) return err } +// ErrSessionArchived 表示目标会话已归档。 +// +// 它与 ErrSessionNotFound **刻意分列**,不是同一件事的两份写法:前者是"这条线索 +// 你知道存在、我也知道,只是不再接受邮件",后者是"没有这条线索"。 +// 两者对调用方是不同的可恢复动作(前者可以 `.new` 另起一条,后者要先确认地址), +// 所以调用方要能 errors.Is 分开判。 +var ErrSessionArchived = errors.New("session is archived") + +// SessionOpenFor 报告 agent 是否可以**往这条会话里发新邮件**。 +// +// 三个判据,来自三张表,缺一不可: +// - 会话存在(ErrSessionNotFound); +// - 会话未归档(ErrSessionArchived)—— 归档是全局、单向的; +// - 发起方是发起方:`s.from_agent = $1` **或**该 Agent 参与过这条线索 +// (from/to/cc 的 EXISTS 子句,与别名寻址 `FindNamedSessionFor` 同一形状)。 +// +// 第三个判据补的是 `AgentCanAccessSession`(repo.go:AgentCanAccessSession)那条 +// 只在读路径被调用的判据:写路径此前**一个都不查**。permission/request 的 +// session_id 完全来自请求体,携带别人的 session_id 就能往那条线索里投一封邮件 —— +// 而 mail.go 的 reply_to 分支同样不查参与方,于是"知道 id 就能往别人的线索里发信"。 +// +// 为什么不复用 AgentCanAccessSession:那个函数判的是"能否改这条线索的**别名**" +// (别名是人记住的寻址入口),范围比"能否发信"窄(Agent 可以发信到一条自己 +// 只是被抄送的线索,别名却不该由它改)。两者是不同的能力,不能互相顶替。 +func SessionOpenFor(ctx context.Context, agentName string, sessionID uuid.UUID) error { + var status string + err := db.DB.QueryRowContext(ctx, + `SELECT s.status FROM sessions s WHERE s.session_id = $1`, sessionID).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return ErrSessionNotFound + } + if err != nil { + return err + } + if status == "archived" { + return ErrSessionArchived + } + var n int + if err := db.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM sessions s + WHERE s.session_id = $1 + AND (s.from_agent = $2 OR EXISTS ( + SELECT 1 FROM mails m + WHERE m.session_id = s.session_id + AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)))`, + sessionID, agentName).Scan(&n); err != nil { + return err + } + if n == 0 { + return ErrSessionNotFound + } + return nil +} + // UpdateSessionAlias 手工改名(人显式指定)。 // // 同时把 alias_source 标为 'manual':人的选择优先于平台自动命名。 @@ -394,6 +471,9 @@ func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error { func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID, fromName, fromWorkspace, toName, toWorkspace, subject, body string, ccList []models.Address) (uuid.UUID, error) { + if err := EnsureSessionOpen(ctx, sessionID); err != nil { + return uuid.Nil, err + } if ccList == nil { ccList = []models.Address{} } @@ -424,7 +504,42 @@ func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUI // // 取会话的 workspace 而不是传参:会话的工作目录在它建立时就定下了, // 而询问发起于那条会话里。 +// +// ★ 这里的 EnsureSessionOpen 是本文件**所有**建邮件函数 +// (CreateMail / CreatePermissionMail / CreateDecisionMail)的共同前置。 +// +// 为什么不把 WHERE 塞进每个 INSERT:分叉判据的成因就是「每个写路径各判一次, +// 而它们判的列不同」(会话表 vs 邮件表)。守卫集中一处, +// 新增一种建邮件的函数就必须经过它 —— 否则下一个新函数又会漏。 +// +// 目的是让**不变量由构造保证**:会话一旦 archived,就再也长不出非归档的邮件行, +// 而两表分叉正是「半活会话」的全部成因 +// (判据:session_status_invariant_test.go)。 +// EnsureSessionOpen 判「会话存在且未归档」,不判参与方。 +// +// 需要它的调用点各自已经判过权限(人类的决策回执只允许收件人本人或管理员), +// 所以这里**刻意不**叠参与方那一格 —— 叠上会让管理员被误挡, +// 而管理员本来就可以给任何线索做决策,那不是越权。 +func EnsureSessionOpen(ctx context.Context, sessionID uuid.UUID) error { + var status string + err := db.DB.QueryRowContext(ctx, + `SELECT status FROM sessions WHERE session_id = $1`, sessionID).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return ErrSessionNotFound + } + if err != nil { + return err + } + if status == "archived" { + return ErrSessionArchived + } + return nil +} + func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string, kind string, multiSelect bool) (uuid.UUID, error) { + if err := EnsureSessionOpen(ctx, sessionID); err != nil { + return uuid.Nil, err + } optsJSON, _ := json.Marshal(options) var multiSelectInt int if multiSelect { @@ -447,6 +562,11 @@ func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, to // CreateDecisionMail 创建人类决策邮件(fromUser → toAgent) func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID uuid.UUID, fromUser, toAgent, decision, note string) (uuid.UUID, error) { + // 守卫对这一条尤其要紧:决策是人在授权页上点的,而被决策的权限邮件可能 + // 在等这一轮的过程中被归档。静默丢弃决策会让 Agent 那边永远等不到回执。 + if err := EnsureSessionOpen(ctx, sessionID); err != nil { + return uuid.Nil, err + } var id uuid.UUID body := decision if note != "" { diff --git a/server/internal/repo/session_status_invariant_test.go b/server/internal/repo/session_status_invariant_test.go new file mode 100644 index 0000000..8b02726 --- /dev/null +++ b/server/internal/repo/session_status_invariant_test.go @@ -0,0 +1,229 @@ +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) + } +}