Files
MailUI4Agents/server/internal/repo/thread_test.go

287 lines
9.1 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"
)
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
s := "巡检报告"
got := preview(s, 4)
if got != "巡..." {
t.Fatalf("按 UTF-8 边界截断失败:%q", got)
}
for i, r := range got {
if r == 0xFFFD {
t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i)
}
}
}
func TestPreviewKeepsShortBodyIntact(t *testing.T) {
if got := preview("短正文", 240); got != "短正文" {
t.Fatalf("未超长却被改动:%q", got)
}
}
// seedReply 插一封回复parent 指向来信。
func seedReply(t *testing.T, sessionID uuid.UUID, parent uuid.UUID, from, to, subject string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(context.Background(), `
INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace,
to_name, to_workspace, subject, body, cc_list, created_at)
VALUES ($1, $2, $3, '', $4, '', $5, 'body', '[]', $6)
RETURNING mail_id
`, sessionID, parent, from, to, subject, nextSeedTime()).Scan(&id)
if err != nil {
t.Fatalf("seed reply: %v", err)
}
return id
}
// 线索根定位:整棵树从根展开,所以这一步错了后面全错。
func TestThreadRootOf(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "root-of")
root := seedMailIn(t, sid, "admin", "dsh", "原件")
mid := seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
leaf := seedReply(t, sid, mid, "admin", "dsh", "Re: Re: 原件")
cases := []struct {
name string
from uuid.UUID
depth int
}{
{"从根本身出发", root, 0},
{"从中间一封出发", mid, 1},
{"从叶子出发", leaf, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotRoot, gotDepth, err := ThreadRootOf(context.Background(), c.from)
if err != nil {
t.Fatalf("ThreadRootOf: %v", err)
}
if gotRoot != root {
t.Errorf("根定位错误:得到 %s期望 %s", gotRoot, root)
}
if gotDepth != c.depth {
t.Errorf("层数错误:得到 %d期望 %d", gotDepth, c.depth)
}
})
}
}
// 这个测试是对话树那次故障的回归:
// 一封抄送给两个 Agent 的邮件收到两个回复,它们互为**兄弟**。
// 旧实现从锚点分「祖先方向 + 子孙方向」两路展开,兄弟既不是锚点的祖先
// 也不是它的子孙,于是整条分支在树里根本不出现。
// 从线索根 BFS 之后,兄弟都是根的子孙,必须一次全出来。
func TestDescendantsFromRootIncludesSiblings(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "siblings")
root := seedMailIn(t, sid, "admin", "dsh", "测试抄送")
replyA := seedReply(t, sid, root, "dsh", "admin", "Re: 测试抄送")
replyB := seedReply(t, sid, root, "opencode", "admin", "Re: 测试抄送")
// 从 replyA 出发定位根,再从根整树展开
gotRoot, anchorDepth, err := ThreadRootOf(context.Background(), replyA)
if err != nil {
t.Fatalf("ThreadRootOf: %v", err)
}
if gotRoot != root || anchorDepth != 1 {
t.Fatalf("根定位错误root=%s depth=%d", gotRoot, anchorDepth)
}
nodes, hasMore, err := DescendantsRaw(context.Background(), gotRoot, 0, 50)
if err != nil {
t.Fatalf("DescendantsRaw: %v", err)
}
if hasMore {
t.Error("三封邮件不该报 hasMore")
}
byID := map[uuid.UUID]TreeMail{}
for _, n := range nodes {
byID[n.ID] = n
}
for name, id := range map[string]uuid.UUID{"根": root, "回复A": replyA, "回复B": replyB} {
if _, ok := byID[id]; !ok {
t.Errorf("%s 不在树里 —— 兄弟分支又丢了", name)
}
}
if byID[root].Depth != 0 {
t.Errorf("根的深度应为 0实际 %d", byID[root].Depth)
}
if byID[replyA].Depth != 1 || byID[replyB].Depth != 1 {
t.Errorf("两个回复都应在深度 1A=%d B=%d", byID[replyA].Depth, byID[replyB].Depth)
}
}
// 转发落在**另一个会话**里,但 parent 仍指向原件。
// 树必须跨会话展开,否则「这条线索转发给谁了」就看不见了。
func TestDescendantsCrossSession(t *testing.T) {
setupTestDB(t)
srcSession := seedSessionRow(t, "fwd-src")
dstSession := seedSessionRow(t, "fwd-dst")
root := seedMailIn(t, srcSession, "admin", "dsh", "原件")
// 转发新会话parent 仍指原件
fwd := seedReply(t, dstSession, root, "admin", "opencode", "Fwd: 原件")
// 转发的下游回复,还在新会话里
fwdReply := seedReply(t, dstSession, fwd, "opencode", "admin", "Re: Fwd: 原件")
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 50)
if err != nil {
t.Fatalf("DescendantsRaw: %v", err)
}
found := map[uuid.UUID]int{}
for _, n := range nodes {
found[n.ID] = n.Depth
}
if _, ok := found[fwd]; !ok {
t.Error("转发不在树里 —— 跨会话展开失效")
}
if _, ok := found[fwdReply]; !ok {
t.Error("转发的下游回复不在树里")
}
if found[fwd] != 1 || found[fwdReply] != 2 {
t.Errorf("跨会话深度错误fwd=%d fwdReply=%d期望 1/2", found[fwd], found[fwdReply])
}
// 会话不同 → session_id 必须如实反映,否则前端无法标出「线索去了别的会话」
for _, n := range nodes {
if n.ID == fwd && n.SessionID != dstSession {
t.Errorf("转发的 session_id 错误:%s期望 %s", n.SessionID, dstSession)
}
}
}
// 分页BFS 顺序稳定,两页拼起来等于一次全取。
func TestDescendantsPaginationStable(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "paging")
root := seedMailIn(t, sid, "admin", "dsh", "原件")
for i := 0; i < 5; i++ {
seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
}
full, hasMoreFull, err := DescendantsRaw(context.Background(), root, 0, 50)
if err != nil {
t.Fatalf("全取: %v", err)
}
if hasMoreFull {
t.Error("6 封邮件一次取完不该报 hasMore")
}
if len(full) != 6 {
t.Fatalf("应有 6 个节点,实际 %d", len(full))
}
page1, hasMore1, err := DescendantsRaw(context.Background(), root, 0, 4)
if err != nil {
t.Fatalf("第一页: %v", err)
}
if !hasMore1 {
t.Error("还有 2 封没取hasMore 应为真")
}
page2, hasMore2, err := DescendantsRaw(context.Background(), root, 4, 4)
if err != nil {
t.Fatalf("第二页: %v", err)
}
if hasMore2 {
t.Error("第二页已取完hasMore 应为假")
}
joined := append(append([]TreeMail{}, page1...), page2...)
if len(joined) != len(full) {
t.Fatalf("两页拼接 %d 个,全取 %d 个", len(joined), len(full))
}
for i := range full {
if joined[i].ID != full[i].ID {
t.Fatalf("第 %d 个节点顺序不一致:分页 %s全取 %s —— BFS 顺序不稳定,"+
"分页加载会重复或漏掉节点", i, joined[i].ID, full[i].ID)
}
}
}
// TreeMailByID 是「锚点没落进 BFS 首页」时的回填手段,深度由调用方给。
func TestTreeMailByIDUsesGivenDepth(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "by-id")
id := seedMailIn(t, sid, "admin", "dsh", "某封")
got, err := TreeMailByID(context.Background(), id, 7)
if err != nil {
t.Fatalf("TreeMailByID: %v", err)
}
if got.ID != id {
t.Errorf("取错了邮件:%s", got.ID)
}
if got.Depth != 7 {
t.Errorf("深度应取调用方给的 7实际 %d", got.Depth)
}
// 树视图只要预览,全文必须被清空 —— 否则整条线索会把几百 KB 正文塞给前端
if got.Body != "" {
t.Errorf("Body 应清空,实际 %q", got.Body)
}
if got.BodyPreview == "" {
t.Error("BodyPreview 应有内容")
}
}
// 抄送列表必须原样带出来:树上两个兄弟节点为什么并列,
// 唯一的解释就是父邮件抄送给了两个人。丢了 cc_list 前端就没法说明。
func TestTreeCarriesCCList(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "cc-carry")
var root uuid.UUID
err := db.DB.QueryRowContext(context.Background(), `
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
subject, body, cc_list, created_at)
VALUES ($1, 'admin', '', 'dsh', '', '抄送两人', 'body',
'[{"name":"opencode","path":"/home","session":"new","raw":"opencode@/home.new"}]', $2)
RETURNING mail_id
`, sid, nextSeedTime()).Scan(&root)
if err != nil {
t.Fatalf("seed: %v", err)
}
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
if err != nil {
t.Fatalf("DescendantsRaw: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("应有 1 个节点,实际 %d", len(nodes))
}
if len(nodes[0].CCList) != 1 {
t.Fatalf("抄送应有 1 人,实际 %d —— cc_list 没带出来", len(nodes[0].CCList))
}
if nodes[0].CCList[0].Raw != "opencode@/home.new" {
t.Errorf("抄送 raw 错误:%q", nodes[0].CCList[0].Raw)
}
}
// 无抄送时 cc_list 必须是空数组而不是 null
// Go 的 nil slice 会序列化成 null前端 node.cc_list.length 直接抛异常。
func TestTreeEmptyCCIsArrayNotNull(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "cc-empty")
root := seedMailIn(t, sid, "admin", "dsh", "无抄送")
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
if err != nil {
t.Fatalf("DescendantsRaw: %v", err)
}
if nodes[0].CCList == nil {
t.Error("cc_list 为 nil会序列化成 null")
}
}