284 lines
9.7 KiB
Go
284 lines
9.7 KiB
Go
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)
|
||
}
|
||
}
|