Files
MailUI4Agents/server/internal/repo/markread_authcolumn_test.go
JianFeeeee 17908c1623 修复: 已读**权威列**静默少行 —— markReadFor 占位符整体错位一格(最后一封永远标不上)
★ 这是我自己那封"重投"的根因。我先在自己的收件箱上量到症状:
  `read_inbox` 列了 **5 封**,落库只有 **4 封**变成已读,**少的正是最后一封**。
  顺着症状读 `repo.go` 才看到机制,不是先读代码再猜。

## 根因:reader 的占位符与调用方的 `$1` 撞号

`markReadFor` 原把 reader **前置**成 `$1`:

    append([]any{reader}, args...)     // → $1=reader, $2=args[0], …

而两个调用方的 `where` 早把 `$1` 用成了 recipient:

    MarkMailsReadFor          : $1=recipient,$2..$(N+1)=ids,args=[recipient, ids...]
    MarkAllInboxReadForSession : $1=recipient,$2=sessionID,args=[recipient, …]

⇒ 绑定表整体**右移一格**,`IN ($2 … $(N+1))` 实际收到 `(recipient, id1 … idN-1)`:

  · **最后一封永远插不进**(idN 从没被绑定)
  · **只传 1 封时一封都不插**(`$2` = recipient)
  · 带 session 那条 `$2=recipient` 被当成 `session_id` ⇒ 同样一行不插

## ★ 为什么长期零痕迹(比 bug 本身重要)

调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,它用的是**没被前置**的 args
⇒ **计数正确**、冗余列也正确,**只有权威列 `mail_reads` 静默少行**。
而未读判据是 `mail_reads`(`unreadFor`)⇒ 邮件**看起来已读、实际仍算未读**
⇒ 重启补投(`catchUp`)时被当新信**重投**。

**原有的 4 个测试全都守着这个 bug**:它们断言的是 `statusOf()` —— 读的正是那列
**冗余**。判据读错了列 ⇒ 守的是"看起来对"的值,不是**决定行为**的那个值。

## 实测(探针 + 变异,两边都跑)

    修复前:单封 mail_reads=0(期望 1);传 4 封 → n=4 但只插 3 行(丢第 4 封)
    修复后:单封 mail_reads=1;传 4 封 → 插 4 行;抄送、幂等、MarkAllInbox 各自 1 行

新判据 5 条在**旧实现上变异验证**:4 条红(含指名"漏标第 [5] 封"),
第 5 条「别人的邮件不该留下行」**正确地不红**(鉴权本来就没坏)。

★ **我中途假绿过一次,如实记**:第一版探针只有 `t.Logf` 没有 `t.Errorf`,
变异态 `go test` 仍 **rc=0** —— 读数(0/3 vs 1/4)确实变了,但**判据没断言**。
这正是本仓那条「判据在,但走不到」。改成 `t.Fatalf` 后才真抓住。

## 我另外自伤了一次(同一个判据抓到我)

`mail_status_readers_test.go` 数的是**原始源码**里 `mails.status` 的出现次数(不剥注释)。
我新写的注释里就有一句"…冗余列 `mails.status` 正确",把清册从 **13 撑到 14** ⇒
`TestMailsStatusReadersAreRegistered` 红。改掉那句措辞后回到 13。
⇒ 记一条:**在那个文件里写注释也会被记账**,说明它数的是"字面出现"而非"真读列"。
(我没有改那个判据 —— 它数得紧是有意的,我改的是自己的措辞。)

## 状态

`go test ./...`:`internal/repo` 仍有一条红 `TestStaleLunarRecurringDoesNotFlood`
(「农历日从 30 变成 29」= **日期相关**)。**我把 `repo.go` 还原成 HEAD 版单跑过,
它同样 FAIL** ⇒ 与本次改动无关的既有红,我没动它。
其余 12 个包全 `ok`。新判据 5/5、原有 `TestMarkMailsReadFor*` 4/4。
2026-09-21 04:25:17 +08:00

134 lines
4.7 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"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
* ★★ 权威列 `mail_reads` 的回归判据(2026-09-20)。
*
* 背景:`markReadFor` 原来把 reader **前置**成 `$1`,而调用方 `where` 早已把 `$1`
* 用成 recipient ⇒ 整张绑定表右移一格 ⇒ `INSERT ... SELECT` 的 `IN` 子句拿到
* `(recipient, id1 … idN-1)`,**最后一封永远插不进**;只传 1 封时一封都不插。
*
* ★ 为什么长期零痕迹(这条比 bug 本身重要):
* 调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,它用的是**没被前置**的
* args ⇒ 计数正确、冗余列 `mails.status` 正确,**只有权威列 `mail_reads` 静默少行**。
* 而未读判据是 `mail_reads`(见 `unreadFor`)⇒ 邮件「看起来已读、实际仍算未读」
* ⇒ 重启补投(`catchUp`)时被当新信**重投**。
*
* ★ 为什么原有 4 个测试(`markread_test.go`)全都漏掉它:
* 它们断言的是 `statusOf()` —— 读的正是那列**冗余** `mails.status`。
* 判据读错了列,于是守着"看起来对"的那个值,而不是**决定行为**的那个值。
* 本文件补的就是"断言权威列"。
*
* ⚠️ 这四条对**顺序**敏感:off-by-one 只丢「最后一个」,所以**必须一次传多个**
* 才有区分力 —— 单封用例在旧实现下也插不进去,多封用例才能看出"丢了尾巴"。
*/
// readRows 读**权威列**:这个读者对某封邮件有几行已读记录。
func readRows(t *testing.T, id uuid.UUID, reader string) int {
t.Helper()
var n int
if err := db.DB.QueryRowContext(context.Background(),
`SELECT count(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = $2`,
id, reader).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// 单封:旧实现下 `IN ($2)` 拿到的是 recipient ⇒ 一行都插不进。
func TestAuthColumnSingleMailMarked(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "bot", "")
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
if got := readRows(t, id, "bot"); got != 1 {
t.Fatalf("权威列 mail_reads 行数 = %d,期望 1 —— "+
"`mails.status` 会说 read,但未读判据读的是 mail_reads ⇒ 这封会被当新信重投", got)
}
}
// ★ 核心:一次传多封时,**每一封**都要有行 —— off-by-one 只会丢最后一封。
func TestAuthColumnAllMailsMarkedNotJustFirstN(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
ids := make([]uuid.UUID, 5)
for i := range ids {
ids[i] = seedMailTo(t, "bot", "")
}
n, err := MarkMailsReadFor(ctx, "bot", ids)
if err != nil {
t.Fatal(err)
}
if n != len(ids) {
t.Fatalf("冗余列影响行数 = %d,期望 %d", n, len(ids))
}
// 逐封断言,并指名是**哪一封**没标上(尾巴那一封最容易漏)。
var missing []int
for i, id := range ids {
if readRows(t, id, "bot") != 1 {
missing = append(missing, i+1)
}
}
if len(missing) > 0 {
t.Fatalf("★ 权威列漏标第 %v 封(共 %d 封)—— "+
"`n=%d` 与 `mails.status` 都会是「对的」,只有 mail_reads 少行;"+
"未读判据读 mail_reads ⇒ 这几封会被重投", missing, len(ids), n)
}
}
// 抄送路径同样要写权威列(它的 where 也把 $1 用成了 recipient)。
func TestAuthColumnCoversCC(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "other", "bot") // 主收件人 other,bot 被抄送
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
if got := readRows(t, id, "bot"); got != 1 {
t.Fatalf("被抄送的邮件在权威列 mail_reads 行数 = %d,期望 1", got)
}
}
// 幂等:重复标记不产生第二行(NOT EXISTS 那半也要在**正确的 reader**上生效)。
func TestAuthColumnIdempotent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "bot", "")
for i := 0; i < 3; i++ {
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
}
if got := readRows(t, id, "bot"); got != 1 {
t.Fatalf("重复标记后权威列行数 = %d,期望 1(幂等)", got)
}
}
// 别人的邮件不该在权威列留下行(鉴权在 WHERE 里,前置错位时这条也可能被绕过)。
func TestAuthColumnNotOwnMailUntouched(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
others := seedMailTo(t, "other", "")
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{others}); err != nil {
t.Fatal(err)
}
if got := readRows(t, others, "bot"); got != 0 {
t.Fatalf("别人的邮件竟在权威列留下了 %d 行 —— 鉴权失效", got)
}
}