★ 这是我自己那封"重投"的根因。我先在自己的收件箱上量到症状:
`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。
134 lines
4.7 KiB
Go
134 lines
4.7 KiB
Go
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)
|
||
}
|
||
}
|