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 里夹带。
230 lines
9.4 KiB
Go
230 lines
9.4 KiB
Go
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)
|
||
}
|
||
}
|