Files
MailUI4Agents/server/internal/repo/readstate_test.go
JianFeeeee b7f624bc45 fix(已读/手势): 读侧也拒绝空读者;mails.status 读者/写者登记成"新增即红";阈值契约显式选 (b)
pi 2026-09-14 的三条裁定,逐条落地。

1. **§2 读侧守卫**(他只钉了写侧,读侧是另一半,而且更隐蔽):`reader` 在查询里是**过滤条件**,
   空串不写坏数据也不报错,只会**算出一个错误的数** —— `unreadFor('')` 的
   `NOT EXISTS(... reader_name = '')` 恒真 ⇒ `CountUnread(ctx,"")` 把**所有**邮件算成未读
   (用户看到"全都没读"),`ListInbox(...,"read")` 恒空。
   二选一里选 **①报错**(当前没有任何调用方需要"汇总"语义;选②就要立刻定义"汇总"是什么,
   而那是个还不存在的需求 —— 将来要就新增一个名字里带汇总的函数,别让空串偷偷兼职)。
   `requireReader` 装到 `ListInboxScoped`/`CountUnread`/`CountUnreadInSession`,
   判据两条:空 reader 三个入口都**必须报错**且**不返回数**;正例防止写成"一律拒绝"。
   变异验证:撤掉 `CountUnread` 的守卫 → `CountUnread("") 必须报错,实际返回 0`。

2. **§1 `mails.status` 收口**:先做他要求的第 1 步(枚举读者,他没有 shell)。
   枚举结果:**没有**"零功能读者"那条路 —— 行级值仍会进邮件 JSON(`GetMailByID`、
   `GetThread` 都 select 它),`migrate.go` 的两处是**一次性回填**(正当),
   `unreadFor`/`readStateFor` 只用它判 `archived`(正当,且已注明"不再作为未读判据")。
   所以走第三步:**登记欠账 + 增量判据**。
   - 「只为兼容保留」**没有**当结语:`mail_status_readers_test.go` 是一张**机器检查的清册**,
     按**文件 + 次数**登记(repo.go 13 / thread.go 1 / migrate.go 4),
     **新增一处读取就红**并强制当场回答"这处合不合规";写入点同样登记
     (多一处 `UPDATE mails SET status` 就红)。
   - **第一次跑就抓到第二处写入**:`MarkAllInboxReadForSession`(整会话批量已读)里还有一句
     `UPDATE mails SET status='read'`,我原先只看到 `MarkMailRead` 那一处 ——
     这正是"新增即红"的价值:靠人 grep 会漏,靠判据不会。
   - 收口路径(写进判据注释):行级 `'read'` 迁到 `markReadFor`;
     但在"详情/线程仍返回行级 status"两处读者迁移**之前**不能只删写入
     —— 那会让 JSON 里的 status 永远是 unread,是另一种错误事实。
   变异验证:在已允许的文件里新增一处读取 → 清册对不上,红。

3. **§3 阈值契约显式选 (b)**:不要求数值一致(`40px`/`600ms` 在触摸屏与鼠标、手机与大屏上
   的人体工学合理值天然不同,强行同值会两边都不舒服),契约改钉**语义层**(左滑/右滑=什么、
   边界是否回弹、"快滑"是感知档)。并写清推论:选了 (b) ⇒ **鸿蒙不得引用 WebUI 的三个数**
   (引了就等于偷偷选了 (a),还是"我抄的那一份"那种)。
2026-09-14 17:08:31 +08:00

305 lines
11 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"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
已读语义:**按读者**记录,而不是邮件行上的一个全局列。
2026-09-13 线上实测:`jianf → dsh` 那封打了招呼、抄送 pi/opencode/zcode/homeagent 的
邮件里,pi 最先回复 ⇒ 它读过了 ⇒ 这封对 dsh 也变成 `read` ⇒ dsh 的
`read_inbox`(默认 unread)返回空,它只能按提示词里的 mail_id 直接读,并在回信里
写下"收件箱列表未展示它,直接按 mail_id 读取成功"。
同一封邮件**对每个读者各自算未读**,是这一组判据要钉住的不变量。
*/
// unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。
func unreadList(t *testing.T, reader string) []uuid.UUID {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "unread", 50)
if err != nil {
t.Fatal(err)
}
out := []uuid.UUID{}
for _, m := range mails {
out = append(out, m.ID)
}
return out
}
func hasID(list []uuid.UUID, id uuid.UUID) bool {
for _, x := range list {
if x == id {
return true
}
}
return false
}
// statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。
func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "all", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
return m.Status
}
}
t.Fatalf("读者 %s 的收件箱里没有 %s", reader, id)
return ""
}
func TestReadStateIsPerRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一封主收件人 alice、抄送 bob 的邮件 —— 正是线上那封信的形状
id := seedMailTo(t, "alice", "bob")
// 初始:两边都未读
if n, _ := CountUnread(ctx, "alice"); n != 1 {
t.Fatalf("alice 初始未读 = %d,期望 1", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("bob 初始未读 = %d,期望 1", n)
}
// alice 读掉它
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatal(err)
}
// ★ 核心判据:alice 的未读清了,bob 的**必须还在**
if n, _ := CountUnread(ctx, "alice"); n != 0 {
t.Fatalf("alice 读过后未读 = %d,期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n)
}
if !hasID(unreadList(t, "bob"), id) {
t.Fatal("★ bob 的 unread 收件箱里必须还能看到这封信")
}
if hasID(unreadList(t, "alice"), id) {
t.Fatal("alice 自己读过了,unread 收件箱不该再有它")
}
// 同一封邮件,两个读者看到的状态不同 —— 这就是"按读者"的直接证据
if got := statusSeenBy(t, "alice", id); got != "read" {
t.Fatalf("alice 看到的 status = %q,期望 read", got)
}
if got := statusSeenBy(t, "bob", id); got != "unread" {
t.Fatalf("★ bob 看到的 status = %q,期望 unread(同一封邮件,按读者算)", got)
}
// 反向对照:bob 也读掉 → 两边都清
if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
t.Fatalf("bob 读过后未读 = %d,期望 0", n)
}
}
// CountUnreadInSession 是会话列表徽标用的,同样必须按读者算。
func TestCountUnreadInSessionIsPerRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "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 := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnreadInSession(ctx, "alice", sid); n != 0 {
t.Fatalf("alice 会话内未读 = %d,期望 0", n)
}
if n, _ := CountUnreadInSession(ctx, "bob", sid); n != 1 {
t.Fatalf("★ bob 会话内未读 = %d,期望 1(徽标不能因为别人读过就归零)", n)
}
}
// 批量标已读(Agent 的 /mail/read)同样只影响调用者自己。
func TestBulkMarkReadOnlyAffectsCaller(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ 批量标已读后 bob 未读 = %d,期望 1", n)
}
// 全部标已读(不带 id)也只能标自己收件箱里的
if _, err := MarkAllInboxReadFor(ctx, "alice"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ 全标已读后 bob 未读 = %d,期望 1", n)
}
}
// 归档是**会话级**的全局状态:归档后对所有人都不可见(unread 与 all 都不给),
// 而且不能因为"没人读过"就把它算成未读。
func TestArchivedStaysArchivedForEveryone(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "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)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
t.Fatal("归档邮件的 status 是 archived,不该出现在 unread 收件箱")
}
// ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在
// `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。
mails, err := ListInbox(ctx, "bob", "all", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
t.Fatal("归档会话里的邮件不该出现在收件箱列表(all 也不该有)")
}
}
}
// 权限决策把邮件记成**决策人**已读:同一条线索上其他收件人不受影响。
func TestPermissionDecisionMarksOnlyDecider(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一封 alice 收、bob 抄送的权限邮件
id := seedMailTo(t, "alice", "bob")
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, kind)
SELECT mail_id, session_id, 'bot', 'q', '["同意","拒绝"]', 'permission' FROM mails WHERE mail_id = $1`,
id); err != nil {
t.Fatal(err)
}
if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "alice"); n != 0 {
t.Fatalf("决策人 alice 未读 = %d,期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ bob 未读 = %d,期望 1(决策是 alice 做的,不该替他标记已读)", n)
}
}
// 决策回执必须与普通邮件区分开:桥靠这个类型判断"这是控制面回执,不是新任务"
// (漏了它,同一封决策就会被当成新邮件再起一轮 —— 2026-09-13 线上缺陷的第二半)。
func TestDecisionMailCarriesTypeAndNote(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
req := seedMailTo(t, "alice", "")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, req).Scan(&sid); err != nil {
t.Fatal(err)
}
const note = "我说了让你拉取仓库到program下你听不懂吗"
id, err := CreateDecisionMail(ctx, sid, req, "jianf", "alice", "拒绝", note)
if err != nil {
t.Fatal(err)
}
var mtype, body string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_type, body FROM mails WHERE mail_id = $1`, id).Scan(&mtype, &body); err != nil {
t.Fatal(err)
}
if mtype != "permission_decision" {
t.Fatalf("决策邮件 mail_type = %q,期望 permission_decision(桥靠它区分「回执」与「新任务」)", mtype)
}
if !strings.Contains(body, note) {
t.Fatalf("决策邮件正文必须带备注,实际:%q", body)
}
}
// ★ 空读者必须报错,不许兜底成某个读者(pi 2026-09-14)。
//
// 这条约定原先只活在注释里:HTTP 层取 `user.Username`(不是请求参数),所以线上不会传空,
// 但 repo 函数本身接受空串 —— 将来新调用方传 `""` 就会写一行 `reader_name=”` 的垃圾:
// 它不属于任何人,却会让"未读"统计出偏差,而且没有任何东西会红。
// 判据要点:不仅"不许插垃圾行",而且**必须返回错误**(静默忽略同样是一种兜底)。
func TestMarkMailReadRejectsEmptyReader(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "")
for _, empty := range []string{"", " "} {
if err := MarkMailRead(ctx, id, empty); err == nil {
t.Fatalf("空 reader(%q)必须报错 —— 否则会静默写入不属于任何人的已读行", empty)
}
}
var n int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM mail_reads WHERE mail_id = $1`, id).Scan(&n); err != nil {
t.Fatalf("查 mail_reads: %v", err)
}
if n != 0 {
t.Fatalf("空 reader 不该写入任何 mail_reads 行,实际写了 %d 行", n)
}
// 正例:正常读者仍然能标记(别把守卫写成"一律拒绝")
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatalf("正常读者不该被守卫拦下:%v", err)
}
}
/*
* ★ 读侧空读者:**必须报错**,不许静默算出一个数(pi 2026-09-14 裁定 §2)。
*
* 写侧那条守卫挡住了"写入不属于任何人的已读行";读侧是另一半,而且更隐蔽:
* 空串作为**过滤条件**时 `NOT EXISTS(... reader_name = '')` 恒真 ⇒
* 所有邮件都被算成未读(用户看到"全都没读"),而 `read` 过滤恒空 ——
* 不报错、不留坏数据,只有一个错数字。所以这里两条都要钉:
* ① 空 reader 的两个入口都返回错误;② 报错时**不返回任何数**(不是"返回 0 顺带报错")。
*/
func TestReadSideRejectsEmptyReader(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedMailTo(t, "alice", "")
for _, empty := range []string{"", " "} {
if n, err := CountUnread(ctx, empty); err == nil {
t.Fatalf("CountUnread(%q) 必须报错,实际返回 %d —— 空读者会静默算出错误的未读数", empty, n)
}
if _, err := ListInbox(ctx, empty, "all", 50); err == nil {
t.Fatalf("ListInbox(%q) 必须报错 —— 空读者是过滤条件,不能兜底", empty)
}
if n, err := CountUnreadInSession(ctx, empty, uuid.Nil); err == nil {
t.Fatalf("CountUnreadInSession(%q) 必须报错,实际返回 %d", empty, n)
}
}
// 正例:正常读者照常工作(别把守卫写成"一律拒绝")
if n, err := CountUnread(ctx, "alice"); err != nil || n != 1 {
t.Fatalf("正常读者 alice 的未读数应为 1,实际 %d(err=%v)", n, err)
}
}