Files
MailUI4Agents/gateway/internal/blob/list_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

177 lines
4.6 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 blob
// List() 的测试 —— 反向 GC 的前提。
//
// 事故背景:附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦
// 记录本身消失(清库、手工 DELETE、迁移对应文件就永远脱离了视野。本机实测
// 磁盘 8 个 blob 里 7 个没有任何库记录,全部来自一次清库,之后一直占着盘。
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestListReturnsStoredContents(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatalf("new store: %v", err)
}
sumA, _, err := s.Put(bytes.NewReader([]byte("alpha")), 0)
if err != nil {
t.Fatalf("put a: %v", err)
}
sumB, _, err := s.Put(bytes.NewReader([]byte("beta")), 0)
if err != nil {
t.Fatalf("put b: %v", err)
}
got, err := s.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Fatalf("应列出 2 个内容,实际 %d%v", len(got), got)
}
for _, sum := range []string{sumA, sumB} {
mod, ok := got[sum]
if !ok {
t.Errorf("缺少 %s", sum[:8])
continue
}
if mod.IsZero() {
t.Errorf("%s 的修改时间为零值 —— GC 靠它判断「是否可能正在上传」", sum[:8])
}
}
}
func TestListEmptyStore(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatalf("new store: %v", err)
}
got, err := s.List()
if err != nil {
t.Fatalf("空库不该报错: %v", err)
}
if len(got) != 0 {
t.Fatalf("空库应返回空,实际 %v", got)
}
}
// **关键用例**`.upload-*` 临时文件绝不能进列表。
//
// 报给调用方会让 GC 去删一个正在写入的文件 —— 上传是「先落盘再入库」,
// 那一瞬间的临时文件既没有库记录也不是合法 sha256 命名。
func TestListSkipsTempUploads(t *testing.T) {
root := t.TempDir()
s, err := New(root)
if err != nil {
t.Fatalf("new store: %v", err)
}
sum, _, err := s.Put(bytes.NewReader([]byte("real")), 0)
if err != nil {
t.Fatalf("put: %v", err)
}
// 模拟一个正在进行的上传
tmp, err := os.CreateTemp(root, ".upload-*")
if err != nil {
t.Fatalf("create temp: %v", err)
}
tmp.WriteString("half written")
tmp.Close()
got, err := s.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 1 {
t.Fatalf("只该列出 1 个真实内容,实际 %d%v", len(got), got)
}
if _, ok := got[sum]; !ok {
t.Errorf("真实内容 %s 应在列表里", sum[:8])
}
for k := range got {
if strings.HasPrefix(k, ".upload-") {
t.Errorf("临时文件 %q 不该出现在列表里 —— GC 会删掉一个正在写入的文件", k)
}
}
}
// 非 sha256 命名的异物一律忽略(人手工丢进去的、别的程序留下的)。
func TestListSkipsForeignFiles(t *testing.T) {
root := t.TempDir()
s, err := New(root)
if err != nil {
t.Fatalf("new store: %v", err)
}
if _, _, err := s.Put(bytes.NewReader([]byte("real")), 0); err != nil {
t.Fatalf("put: %v", err)
}
// 放几个不合法命名的文件在两级目录里
for _, name := range []string{"README", "ABCDEF", "notasha256"} {
dir := filepath.Join(root, "ab", "cd")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
got, err := s.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 1 {
t.Fatalf("异物不该被列出,实际 %d%v", len(got), got)
}
}
// 修改时间必须是真实的文件时间 —— GC 用它跳过「可能正在上传」的文件。
func TestListReportsRealModTime(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatalf("new store: %v", err)
}
before := time.Now().Add(-time.Second)
sum, _, err := s.Put(bytes.NewReader([]byte("timed")), 0)
if err != nil {
t.Fatalf("put: %v", err)
}
after := time.Now().Add(time.Second)
got, err := s.List()
if err != nil {
t.Fatalf("list: %v", err)
}
mod := got[sum]
if mod.Before(before) || mod.After(after) {
t.Fatalf("修改时间 %v 不在 [%v, %v] 内", mod, before, after)
}
}
// 根目录不存在时必须报错,不能返回空列表。
//
// 返回空会让调用方以为「库里什么都没有」—— 那个判断会传导到 GC 的计数上,
// 让运维以为磁盘是干净的。
func TestListFailsOnMissingRoot(t *testing.T) {
root := t.TempDir()
s, err := New(root)
if err != nil {
t.Fatalf("new store: %v", err)
}
if err := os.RemoveAll(root); err != nil {
t.Fatalf("rm root: %v", err)
}
if _, err := s.List(); err == nil {
t.Fatal("根目录不存在时必须报错,返回空列表会让调用方以为库是空的")
}
}