287 lines
9.1 KiB
Go
287 lines
9.1 KiB
Go
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")
|
||
}
|
||
}
|