Files
MailUI4Agents/plugins/homeagent-mail-bridge/attach_test.go
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

132 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// attach.go 的测试。
//
// 事故背景本轮实测生产环境homeagent 的 upload_attachment 按
// **顶层** `attachment_id` 解析服务端响应,而服务端返回的是
// `{"attachment":{"attachment_id":…}}` —— 三个字段全解成零值。
//
// 那是最坏的一种失败形状上传其实成功了HTTP 200、文件已落盘、库里已登记
// 没有任何一层报错,但模型看到的是 `id= filename= size=0KB`。拿着空 id 它
// 发不出这个附件24 小时后 GC 把那个没人引用的文件清掉,现场不留痕迹。
//
// 同时 send_mail 根本没声明 attachment_ids 参数,且服务端解请求体时没开
// DisallowUnknownFields —— 实测传 `attachments:[{...}]` 返回 200
// 而那封邮件的附件数是 0。两个 bug 叠起来homeagent 的附件发送链路
// **从来没成功过一次**。
import (
"strings"
"testing"
)
// ─── stringList ───
func TestStringList_JSONShape(t *testing.T) {
// 运行时的真实形状SDK 把工具参数当 JSON 解出来,
// 数组是 []interface{},元素是 interface{}(string)
got := stringList([]interface{}{"a", "b"})
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("want [a b], got %#v", got)
}
}
func TestStringList_NotTypedSlice(t *testing.T) {
// 这条是整个文件存在的理由:写 args[...].([]string) 会拿到 nil。
// 若哪天有人把 stringList 换回类型断言,这个用例会失败。
var v interface{} = []interface{}{"id-1"}
if _, ok := v.([]string); ok {
t.Fatal("[]interface{} 不该能断言成 []string —— 前提变了,附件字段会静默消失")
}
if got := stringList(v); len(got) != 1 || got[0] != "id-1" {
t.Fatalf("stringList 必须能吃下 []interface{}got %#v", got)
}
}
func TestStringList_SingleString(t *testing.T) {
// 模型常见的偷懒写法。意图无歧义,拒绝它只换来一次重试。
got := stringList("only-one")
if len(got) != 1 || got[0] != "only-one" {
t.Fatalf("want [only-one], got %#v", got)
}
}
func TestStringList_DropsBadElementsKeepsGood(t *testing.T) {
// 逐项校验而非整批放弃:否则「三个附件里有一个写错」会变成
// 「一个附件都没发出」。
got := stringList([]interface{}{"good-1", nil, 42, "", " ", "good-2", map[string]interface{}{}})
if len(got) != 2 || got[0] != "good-1" || got[1] != "good-2" {
t.Fatalf("want [good-1 good-2], got %#v", got)
}
}
func TestStringList_TrimsSpace(t *testing.T) {
got := stringList([]interface{}{" padded-id "})
if len(got) != 1 || got[0] != "padded-id" {
t.Fatalf("want [padded-id], got %#v", got)
}
}
func TestStringList_EmptyAndNil(t *testing.T) {
// nil / 空数组 / 全是坏元素都要返回空,让调用方省略 payload 字段而不是传 []
for name, in := range map[string]interface{}{
"nil": nil,
"空数组": []interface{}{},
"空字符串": "",
"只有空白": " ",
"全是坏元素": []interface{}{nil, 1, false},
"不认识的类型": map[string]interface{}{"a": 1},
"数字": 42,
"全是空串元素": []interface{}{"", " "},
} {
if got := stringList(in); len(got) != 0 {
t.Fatalf("%s 应返回空got %#v", name, got)
}
}
}
func TestStringList_TypedSliceAlsoWorks(t *testing.T) {
// 单测里手写参数会走到这一支
got := stringList([]string{"x", "", "y"})
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
t.Fatalf("want [x y], got %#v", got)
}
}
// ─── formatSize ───
func TestFormatSize(t *testing.T) {
cases := []struct {
in int64
want string
}{
// 关键用例:小文件不能显示成 0KB。原先写的是 size/1024 + "KB"
// 一个 800 字节的附件显示成 `0KB`,模型会据此认为上传失败。
{0, "0 B"},
{1, "1 B"},
{800, "800 B"},
{1023, "1023 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{28, "28 B"}, // 本轮探针的真实大小
{1024 * 1024, "1.0 MB"},
{25 << 20, "25.0 MB"}, // 附件上限
{-1, "0 B"}, // 不该出现,但不能打印成 -1 B
}
for _, c := range cases {
if got := formatSize(c.in); got != c.want {
t.Errorf("formatSize(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestFormatSizeNeverZeroKBForNonEmpty(t *testing.T) {
// 负向对照的固化任何非空文件都不该显示成「0」开头的大小
for _, n := range []int64{1, 10, 100, 800, 1023} {
got := formatSize(n)
if strings.HasPrefix(got, "0 ") || got == "0KB" || strings.HasPrefix(got, "0.0") {
t.Errorf("formatSize(%d) = %q —— 非空文件不能显示成零", n, got)
}
}
}