chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,286 @@
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")
}
}