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("两个回复都应在深度 1:A=%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") } }