Files
Cyrene_v2/internal/runtime/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

197 lines
6.4 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 history
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件是文档引用列(chat_messages.document,design/24 §2.4)的存储测试:
// 引用与统计往返一致、库里**任何一列都不含抽取正文**、坏 JSON 不静默当成
// 「没有附件」,以及早于 0015 建出的旧表能被幂等前滚。
// TestDocumentRefRoundTrip 验证「引用 + 统计」落库再读出后逐字段一致。该列是
// 不透明 JSON:存储不解释、不裁剪它。
func TestDocumentRefRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
ref := DocumentRef{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: DocumentStatusExtracted,
}
raw, err := EncodeDocument(ref)
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "总结一下", Document: raw, CreatedAt: 1000}); err != nil {
t.Fatalf("append: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("len = %d, want 1", len(msgs))
}
got, err := DecodeDocument(msgs[0].Document)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got == nil {
t.Fatal("document ref lost in round trip")
}
if *got != ref {
t.Fatalf("ref = %+v, want %+v", *got, ref)
}
}
// TestDocumentRefEmptyMeansNoAttachment 验证「没有附件」与「有附件」在库里、
// 在 DTO 里都有确定区别:空引用编码成空串,空串解码回 nil。
func TestDocumentRefEmptyMeansNoAttachment(t *testing.T) {
t.Parallel()
raw, err := EncodeDocument(DocumentRef{})
if err != nil {
t.Fatalf("encode zero ref: %v", err)
}
if raw != "" {
t.Fatalf("raw = %q, want empty column", raw)
}
ref, err := DecodeDocument("")
if err != nil {
t.Fatalf("decode empty: %v", err)
}
if ref != nil {
t.Fatalf("ref = %+v, want nil", ref)
}
}
// TestDecodeDocumentRejectsMalformed 验证坏 JSON 返回错误而不是 (nil, nil):
// 解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要消灭的误解。
func TestDecodeDocumentRejectsMalformed(t *testing.T) {
t.Parallel()
if _, err := DecodeDocument("{not json"); err == nil {
t.Fatal("DecodeDocument(malformed) = nil, want an error")
}
}
// TestPersistedDocumentHoldsNoBody 断言抽取正文绝不落库(design/24 §2.4 的
// 红线):正文留在 <DataDir>/extracted 的 sidecar,历史只存引用与统计。
// 它把 chat_messages 的每一列拼起来扫一遍正文,因此任何一列(包括将来新增的
// 那些)都逃不掉。
func TestPersistedDocumentHoldsNoBody(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
const body = "抽取正文不可落库·SECRET-BODY-0123456789"
raw, err := EncodeDocument(DocumentRef{
Name: "aaaabbbbccccdddd.txt",
MIME: "text/plain",
Size: int64(len(body)),
Chars: len(body),
Status: DocumentStatusExtracted,
})
if err != nil {
t.Fatalf("encode: %v", err)
}
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "这份文档讲了什么", Document: raw})
if err != nil {
t.Fatalf("append: %v", err)
}
const allColumns = `SELECT ifnull(id,'')||ifnull(session_id,'')||ifnull(role,'')||ifnull(content,'')||
ifnull(mode,'')||ifnull(mood,'')||ifnull(image,'')||ifnull(document,'')||ifnull(thinking,'')||
ifnull(platform,'')||ifnull(device_id,'')||ifnull(sender_name,'')||ifnull(adapter_id,'')||
ifnull(persona_id,'')||ifnull(client_msg_id,'')||ifnull(created_at,'')
FROM chat_messages WHERE id = ?`
var all string
if err := st.db.QueryRowContext(ctx, allColumns, full.ID).Scan(&all); err != nil {
t.Fatalf("scan all columns: %v", err)
}
if strings.Contains(all, body) {
t.Fatalf("抽取正文落库了(该行全部列 = %q)", all)
}
if !strings.Contains(all, "aaaabbbbccccdddd.txt") {
t.Fatalf("文档引用应落库,但库里没有(该行全部列 = %q)", all)
}
}
// TestNewStoreRollsForwardDocumentColumn 验证早于 0015 建出的 chat_messages 表
// 会被 NewStore 幂等前滚出 document 列(与 client_msg_id 同一手法):全新库由
// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带引用的消息。
func TestNewStoreRollsForwardDocumentColumn(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
const legacyTable = `
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
mode TEXT,
mood TEXT,
image TEXT,
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
persona_id TEXT NOT NULL DEFAULT '',
client_msg_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
if _, err := conn.ExecContext(ctx, legacyTable); err != nil {
t.Fatalf("create legacy table: %v", err)
}
st, err := NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new store: %v", err)
}
raw, err := EncodeDocument(DocumentRef{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Status: DocumentStatusListed})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "带附件", Document: raw, CreatedAt: 2000}); err != nil {
t.Fatalf("append after roll forward: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].Document != raw {
t.Fatalf("msgs = %+v, want the document ref back", msgs)
}
}
// TestDocumentStatusConstants 钉住三个状态取值:它们经持久化直通 DTO,客户端
// 按这些字面量渲染「读过了 / 只有清单 / 读不到」(design/24 §6.1)。
func TestDocumentStatusConstants(t *testing.T) {
t.Parallel()
want := map[string]string{
DocumentStatusExtracted: "extracted",
DocumentStatusListed: "listed",
DocumentStatusUnreadable: "unreadable",
}
for got, expected := range want {
if got != expected {
t.Fatalf("status = %q, want %q", got, expected)
}
}
}