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 里夹带。
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
229
server/internal/repo/session_status_invariant_test.go
Normal file
229
server/internal/repo/session_status_invariant_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user