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 的 // 红线):正文留在 /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) } } }