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) } }