Files
Cyrene_v2/internal/services/gateway/history_document_test.go
T
AskaEth 6b2b07cbef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): document 内容类型与持久化(design/24 S2)
- model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 +
  ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。
- 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest:
  本期 document 永不抵达编码器,且绝不静默丢块。
- history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是
  落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。
- chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO;
  session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。
- 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。
变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。
2026-10-04 17:07:24 +08:00

127 lines
4.6 KiB
Go

package gateway
import (
"encoding/json"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是历史 DTO 的 document 对象与持久化取值的测试(design/24 §2.4、§6.1):
// 引用与统计往返、**不回传抽取正文**、坏列可见,以及 session_persist 的
// messageTextImageDocument 取值。
// TestHistoryMessageDocumentCarriesReferenceOnly 验证 document 对象只带
// 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段,
// 这里就会红。
func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocument(history.DocumentRef{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: history.DocumentStatusExtracted,
})
if err != nil {
t.Fatalf("encode: %v", err)
}
wire := toHistoryMessage(history.Message{ID: "m1", SessionID: "web", Role: "user", Content: "总结这份报告", Document: raw})
b, err := json.Marshal(wire)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
doc, ok := decoded["document"].(map[string]any)
if !ok {
t.Fatalf("document object missing in %s", b)
}
keys := make([]string, 0, len(doc))
for k := range doc {
keys = append(keys, k)
}
sort.Strings(keys)
if got := strings.Join(keys, ","); got != "chars,mime,name,size,status,truncated" {
t.Fatalf("document keys = %q, want chars,mime,name,size,status,truncated", got)
}
if doc["name"] != "0123456789abcdef0123456789abcdef.txt" || doc["status"] != history.DocumentStatusExtracted {
t.Fatalf("document = %v, want the persisted reference", doc)
}
}
// TestHistoryMessageDocumentAbsentWhenNoColumn 验证没有附件的消息省略该字段:
// 空列与「带附件」必须可区分(省略 = 没有附件)。
func TestHistoryMessageDocumentAbsentWhenNoColumn(t *testing.T) {
t.Parallel()
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "没有附件"})
b, err := json.Marshal(wire)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), `"document"`) {
t.Fatalf("empty column should omit document, got %s", b)
}
}
// TestHistoryMessageDocumentMalformedColumnIsVisible 验证列里是坏 JSON 时
// 不静默当成「没有附件」:回一个标着 status=unreadable 的最小对象,让客户端
// 看得见这条引用读不出来(design/24 §4.4 的「失败必须可见」)。
func TestHistoryMessageDocumentMalformedColumnIsVisible(t *testing.T) {
t.Parallel()
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "带附件", Document: "{not json"})
if wire.Document == nil {
t.Fatal("malformed document column was silently dropped")
}
if wire.Document.Status != history.DocumentStatusUnreadable {
t.Fatalf("status = %q, want %q", wire.Document.Status, history.DocumentStatusUnreadable)
}
}
// TestMessageTextImageDocument 验证持久化取值:文本、首个图片 URL 与首个文档
// 引用各归各列,且文档列是引用(JSON 小对象)而不是正文。
func TestMessageTextImageDocument(t *testing.T) {
t.Parallel()
doc := model.Document{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", URL: "/api/v1/documents/aaaabbbbccccdddd.txt", Size: 1234}
m := model.DocumentMessage(model.RoleUser, "读一下", doc)
m.Content = append(m.Content, model.Content{Type: model.ContentTypeImage, ImageURL: "data:image/png;base64,abc"})
text, image, raw, err := messageTextImageDocument(m)
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if text != "读一下" {
t.Fatalf("text = %q, want 读一下", text)
}
if image != "data:image/png;base64,abc" {
t.Fatalf("image = %q", image)
}
ref, err := history.DecodeDocument(raw)
if err != nil {
t.Fatalf("decode document: %v", err)
}
if ref == nil || ref.Name != doc.Name || ref.MIME != doc.MIME || ref.Size != doc.Size {
t.Fatalf("ref = %+v, want name/mime/size of %+v", ref, doc)
}
}
// TestMessageTextImageDocumentWithoutDocument 验证没有文档部件时文档列为空
// (而不是 "{}"):拼写空对象会让读取侧把它当成一条读不出来的附件。
func TestMessageTextImageDocumentWithoutDocument(t *testing.T) {
t.Parallel()
_, _, raw, err := messageTextImageDocument(model.TextMessage(model.RoleUser, "只有文本"))
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if raw != "" {
t.Fatalf("document = %q, want empty", raw)
}
}