package repo import ( "context" "strings" "testing" "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) // 这一组测试守的是一条不变量:**`.new` 建出来的会话必须立刻可寻址**。 // // 破坏方式很隐蔽 —— 邮件照样送达、收件人照样能回复那一封,只有「指名投进同一条 // 会话」这个动作静默失败(`FindNamedSessionFor` 查不到未命名会话),再发一次 // `.new` 就多一条平行会话。所以这里的断言都落在「事后能不能按别名找回来」上, // 而不是「有没有报错」。 func TestAutoAliasForCombinesNameAndSubject(t *testing.T) { // 名字与主题都要在:只用主题时「服务恢复验证」这类通用主题会跨 Agent 撞名, // 只用名字则同一个 Agent 的会话全叫 dsh-2、dsh-3,看不出在聊什么。 got := AutoAliasFor("dsh", "重构导入路径") if got != "dsh-重构导入路径" { t.Fatalf("want dsh-重构导入路径, got %q", got) } } func TestAutoAliasForStripsAddressingChars(t *testing.T) { // 别名要参与 name@path.session 的切分,含 . / @ 或空白会让地址解析歧义。 // 主题里的 Markdown 与标点噪声([联调]、—、:)也不该变成一串破折号。 cases := []struct{ in, want string }{ {"[联调] llmsproxy / ModelRouter — 请提供部署现状", "x-联调-llmsproxy-ModelRouter-请提供部署现状"}, {"a.b.c", "x-a-b-c"}, {"has spaces", "x-has-spaces"}, {"user@host", "x-user-host"}, {"Re: 服务恢复验证", "x-Re-服务恢复验证"}, } for _, c := range cases { got := AutoAliasFor("x", c.in) if got != c.want { t.Errorf("AutoAliasFor(x, %q) = %q, want %q", c.in, got, c.want) } if strings.ContainsAny(got, ". \t/@") { t.Errorf("别名 %q 含寻址保留字符,会破坏地址解析", got) } } } func TestAutoAliasForAvoidsReservedNew(t *testing.T) { // "new" 是 session 位的保留字。别名若正好是它,`x@/p.new` 就同时是 // 「投进这条会话」与「再建一条」两种意思。 if got := AutoAliasFor("", "new"); got == "new" { t.Fatal("别名不得为保留字 new") } } func TestAutoAliasForEmptyMaterial(t *testing.T) { // 素材里一个可用字符都没有时返回空串,交由 EnsureSessionAlias 走 uuid 兜底, // 而不是在这里编造一个名字。 if got := AutoAliasFor("", "···"); got != "" { t.Fatalf("want empty, got %q", got) } } func TestAutoAliasForTruncatesAtByteLimit(t *testing.T) { // session_alias 是 VARCHAR(128),而中文主题很容易超;按字节截断时 // 不能把多字节字符切坏(切坏后写库会得到非法 UTF-8)。 got := AutoAliasFor("bot", strings.Repeat("中", 200)) if len(got) > aliasMaxBytes { t.Fatalf("别名 %d 字节,超过上限 %d", len(got), aliasMaxBytes) } if !utf8Valid(got) { t.Fatal("截断切坏了多字节字符") } } func utf8Valid(s string) bool { for _, r := range s { if r == '\uFFFD' { return false } } return true } func TestEnsureSessionAliasMakesNewSessionAddressable(t *testing.T) { setupTestDB(t) ctx := context.Background() // 复刻 `.new` 的真实路径:CreateSession(alias=nil) —— 别名是 NULL。 sid, err := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail") if err != nil { t.Fatalf("建会话: %v", err) } seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail") // 命名前:按别名找不回来(这正是线上那条断链) if _, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", "dsh-重构导入路径"); err == nil { t.Fatal("未命名会话竟然能按别名找到,测试前提不成立") } alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径")) if err != nil { t.Fatalf("命名: %v", err) } if alias == "" { t.Fatal("别名为空") } // 命名后:收件方能指名投回这条会话,而不是又开一条 got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias) if err != nil { t.Fatalf("按别名寻址: %v", err) } if got != sid { t.Fatalf("别名 %q 指向 %s,应指向 %s", alias, got, sid) } } func TestEnsureSessionAliasKeepsExistingName(t *testing.T) { setupTestDB(t) ctx := context.Background() // 调用方显式命名过(发信时传了 session_alias),标记为 manual。 // 自动命名绝不能覆盖它 —— 人记住的地址不该下一秒失效。 want := "llmsproxy-joint" sid, err := CreateSession(ctx, &want, "dsh", "联调", "/home/program/llmsproxy") if err != nil { t.Fatalf("建会话: %v", err) } got, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("opencode", "别的主题")) if err != nil { t.Fatalf("命名: %v", err) } if got != want { t.Fatalf("已有别名被改写成 %q,应保持 %q", got, want) } } func TestEnsureSessionAliasIsIdempotent(t *testing.T) { setupTestDB(t) ctx := context.Background() // 默认会话路径上 EnsureSessionAlias 会被每封邮件调用一次 // (会话可能是刚建的也可能是复用的),因此重复调用必须返回同一个名字。 sid, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws") first, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证")) if err != nil { t.Fatalf("首次命名: %v", err) } second, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证")) if err != nil { t.Fatalf("二次命名: %v", err) } if first != second { t.Fatalf("重复调用给出两个别名: %q vs %q", first, second) } } func TestEnsureSessionAliasSuffixesOnCollision(t *testing.T) { setupTestDB(t) ctx := context.Background() // 同一个 Agent + 同一主题会反复出现(「服务恢复验证」发两次)。 // 别名全局唯一(负责寻址),撞名必须让位而不是报错 —— 发信不该因为 // 主题重复而失败。 want := AutoAliasFor("dsh", "服务恢复验证") a, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws") aliasA, err := EnsureSessionAlias(ctx, a, want) if err != nil { t.Fatalf("首个会话命名: %v", err) } b, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws") aliasB, err := EnsureSessionAlias(ctx, b, want) if err != nil { t.Fatalf("第二个会话命名: %v", err) } if aliasA == aliasB { t.Fatalf("两条会话拿到同一个别名 %q", aliasA) } if aliasB != want+"-2" { t.Fatalf("撞名后缀应为 %s-2,实际 %q", want, aliasB) } // 两个别名各自指向自己那条会话,没有相互覆盖 for alias, expect := range map[string]uuid.UUID{aliasA: a, aliasB: b} { var got uuid.UUID err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&got) if err != nil { t.Fatalf("查别名 %q: %v", alias, err) } if got != expect { t.Errorf("别名 %q 指向 %s,应指向 %s", alias, got, expect) } } } func TestEnsureSessionAliasFallsBackToUUID(t *testing.T) { setupTestDB(t) ctx := context.Background() // 主题与名字都拿不出可用字符时(AutoAliasFor 返回空串), // 仍必须得到一个能寻址的别名 —— 丑名字远胜于没有名字。 sid, _ := CreateSession(ctx, nil, "admin", "···", "/tmp/ws") alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("", "···")) if err != nil { t.Fatalf("兜底命名: %v", err) } if alias == "" { t.Fatal("兜底后别名仍为空") } if !strings.HasPrefix(alias, "session-") { t.Fatalf("兜底别名应形如 session-xxxxxxxx,实际 %q", alias) } } func TestEnsureSessionAliasSurfacesInSuggestions(t *testing.T) { setupTestDB(t) ctx := context.Background() // 补全候选只收「有别名的非归档会话」(`session_alias IS NOT NULL AND <> ''`)。 // 自动命名的另一半价值就在这里:命名前这条会话在人类的三段式补全里 // 也是不可见的,人同样只能靠回复某封邮件才能续谈。 seedUser(t, ctx, "admin") sid, _ := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail") seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail") before, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail") if err != nil { t.Fatalf("补全(命名前): %v", err) } for _, c := range before { if c.Source == "mail" { t.Fatalf("未命名会话不该出现在补全里,却拿到 %+v", c) } } alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径")) if err != nil { t.Fatalf("命名: %v", err) } after, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail") if err != nil { t.Fatalf("补全(命名后): %v", err) } found := false for _, c := range after { if c.Alias == alias { found = true } } if !found { t.Fatalf("命名后 %q 仍未出现在补全候选里: %+v", alias, after) } } // seedMailForSession 往会话里塞一封邮件。 // FindNamedSessionFor 与 SuggestSessionCandidates 都要求「该收件人参与过」, // 只建会话不建邮件的话两者都查不到,测试会得出错误结论。 func seedMailForSession(t *testing.T, sid uuid.UUID, from, to, workspace string) { t.Helper() if _, err := CreateMail(context.Background(), sid, nil, from, "", to, workspace, "主题", "正文", nil); err != nil { t.Fatalf("seed mail: %v", err) } } // seedUser 插一个用户。SuggestSessionCandidates 的可见性条件要查 users 表。 func seedUser(t *testing.T, ctx context.Context, username string) { t.Helper() _, err := db.DB.ExecContext(ctx, `INSERT INTO users (username, display_name, password_hash, role) VALUES ($1, $1, 'x', 'admin')`, username) if err != nil { t.Fatalf("seed user %s: %v", username, err) } }