- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。
- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。
- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。
- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。
- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。
- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
328 lines
12 KiB
Go
328 lines
12 KiB
Go
package history
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
)
|
||
|
||
// 本文件是文档引用列(chat_messages.document,design/24 §2.4/§6.1)的存储测试:
|
||
// 引用与统计往返一致、旧单对象形状兼容、库里**任何一列都不含抽取正文**、坏内容
|
||
// 不静默当成「没有附件」、上限报错、按 id 回写抽取结论,以及早于 0015 建出的
|
||
// 旧表能被幂等前滚。
|
||
|
||
// TestDocumentRefRoundTrip 验证「引用 + 统计」落库再读出后逐字段一致、顺序不变。
|
||
// 该列是不透明 JSON:存储不解释、不裁剪它。
|
||
func TestDocumentRefRoundTrip(t *testing.T) {
|
||
t.Parallel()
|
||
st := newStore(t)
|
||
ctx := context.Background()
|
||
|
||
refs := []DocumentRef{
|
||
{
|
||
Name: "0123456789abcdef0123456789abcdef.txt",
|
||
MIME: "text/plain",
|
||
Size: 12345,
|
||
Chars: 8000,
|
||
Truncated: true,
|
||
Status: DocumentStatusExtracted,
|
||
},
|
||
{Name: "fedcba9876543210fedcba9876543210.md", MIME: "text/markdown", Size: 42, Status: DocumentStatusListed},
|
||
{
|
||
Name: "00112233445566778899aabbccddeeff.pdf",
|
||
MIME: "application/pdf",
|
||
Size: 2048,
|
||
Status: DocumentStatusUnreadable,
|
||
Reason: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)",
|
||
},
|
||
}
|
||
raw, err := EncodeDocuments(refs)
|
||
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 := DecodeDocuments(msgs[0].Document)
|
||
if err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(got) != len(refs) {
|
||
t.Fatalf("refs = %+v, want %d 条(顺序与写入一致)", got, len(refs))
|
||
}
|
||
for i := range refs {
|
||
if got[i] != refs[i] {
|
||
t.Fatalf("refs[%d] = %+v, want %+v", i, got[i], refs[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDecodeDocumentsAcceptsLegacySingleObject 钉住旧行兼容:S2/S3 早期把
|
||
// document 列写成**单个**对象,那些行里带着一条真实引用,绝不能因为形状老就被
|
||
// 当成坏数据丢掉(这正是缺口①的另一半)。
|
||
func TestDecodeDocumentsAcceptsLegacySingleObject(t *testing.T) {
|
||
t.Parallel()
|
||
const legacy = `{"name":"aaaabbbbccccdddd.txt","mime":"text/plain","size":1234,"status":"extracted"}`
|
||
refs, err := DecodeDocuments(legacy)
|
||
if err != nil {
|
||
t.Fatalf("decode legacy: %v", err)
|
||
}
|
||
if len(refs) != 1 {
|
||
t.Fatalf("legacy refs = %+v, want 长度 1 的数组", refs)
|
||
}
|
||
if refs[0].Name != "aaaabbbbccccdddd.txt" || refs[0].Status != DocumentStatusExtracted || refs[0].Size != 1234 {
|
||
t.Fatalf("legacy refs[0] = %+v, want 旧行里的那条引用", refs[0])
|
||
}
|
||
}
|
||
|
||
// TestDocumentRefEmptyMeansNoAttachment 验证「没有附件」与「有附件」在库里、
|
||
// 在 DTO 里都有确定区别:空列表编码成空串,空串解码回 nil。
|
||
func TestDocumentRefEmptyMeansNoAttachment(t *testing.T) {
|
||
t.Parallel()
|
||
raw, err := EncodeDocuments(nil)
|
||
if err != nil {
|
||
t.Fatalf("encode zero refs: %v", err)
|
||
}
|
||
if raw != "" {
|
||
t.Fatalf("raw = %q, want empty column", raw)
|
||
}
|
||
refs, err := DecodeDocuments("")
|
||
if err != nil {
|
||
t.Fatalf("decode empty: %v", err)
|
||
}
|
||
if refs != nil {
|
||
t.Fatalf("refs = %+v, want nil", refs)
|
||
}
|
||
}
|
||
|
||
// TestDecodeDocumentsRejectsMalformed 验证坏内容返回包装了 ErrBadDocuments 的错误
|
||
// 而不是 (nil, nil):解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要
|
||
// 消灭的误解(调用方按 errors.Is 判类后打带来源的 WARN)。
|
||
func TestDecodeDocumentsRejectsMalformed(t *testing.T) {
|
||
t.Parallel()
|
||
for _, raw := range []string{"{not json", "not json at all", `{"name":42}`} {
|
||
if _, err := DecodeDocuments(raw); !errors.Is(err, ErrBadDocuments) {
|
||
t.Fatalf("DecodeDocuments(%q) = %v, want ErrBadDocuments", raw, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestEncodeDocumentsRejectsUnnamedRef 验证无名引用报错而不是写进库:名字是 URL
|
||
// 与展示身份的唯一来源,存一条没有名字的引用等于存一条读不出来的附件。
|
||
func TestEncodeDocumentsRejectsUnnamedRef(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := EncodeDocuments([]DocumentRef{{MIME: "text/plain", Size: 12}}); !errors.Is(err, ErrBadDocuments) {
|
||
t.Fatalf("EncodeDocuments(unnamed) = %v, want ErrBadDocuments", err)
|
||
}
|
||
}
|
||
|
||
// TestDocumentCountCapIsFive 把上限的**值**单独钉在设计定稿上
|
||
// (design/24 §9.4-3、§6.1)。为什么必须单独钉一次:下面的超限用例只证明「逻辑
|
||
// 自洽」,上限被悄悄改宽时它们照样绿(S3-B 的测试质量教训)。
|
||
func TestDocumentCountCapIsFive(t *testing.T) {
|
||
t.Parallel()
|
||
if maxDocumentsPerMessage != 5 {
|
||
t.Fatalf("单条消息文档引用上限 = %d, want 5(design/24 §6.1 的定稿值)", maxDocumentsPerMessage)
|
||
}
|
||
}
|
||
|
||
// TestDocumentCountCapRejectsSix 覆盖防御性上限:超过 5 条引用时读写两端都**报错**
|
||
// 而不是悄悄裁掉几条 —— 悄悄裁剪就是静默丢失,而 6 条只可能来自坏数据或外部改动。
|
||
// 六条是字面量而不是由常量算出,常量本身另由 TestDocumentCountCapIsFive 钉住。
|
||
func TestDocumentCountCapRejectsSix(t *testing.T) {
|
||
t.Parallel()
|
||
six := []DocumentRef{
|
||
{Name: "a.txt"}, {Name: "b.txt"}, {Name: "c.txt"},
|
||
{Name: "d.txt"}, {Name: "e.txt"}, {Name: "f.txt"},
|
||
}
|
||
if _, err := EncodeDocuments(six); !errors.Is(err, ErrTooManyDocuments) {
|
||
t.Fatalf("EncodeDocuments(6) = %v, want ErrTooManyDocuments", err)
|
||
}
|
||
const sixJSON = `[{"name":"a.txt"},{"name":"b.txt"},{"name":"c.txt"},{"name":"d.txt"},{"name":"e.txt"},{"name":"f.txt"}]`
|
||
if _, err := DecodeDocuments(sixJSON); !errors.Is(err, ErrTooManyDocuments) {
|
||
t.Fatalf("DecodeDocuments(6) = %v, want ErrTooManyDocuments", err)
|
||
}
|
||
}
|
||
|
||
// 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 := EncodeDocuments([]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)
|
||
}
|
||
}
|
||
|
||
// TestUpdateMessageDocumentPersistsConclusion 覆盖状态回写的写入端
|
||
// (design/24 §6.1):回合结束后按消息 id 把抽取结论补进 document 列,且**只**
|
||
// 改这一列。
|
||
func TestUpdateMessageDocumentPersistsConclusion(t *testing.T) {
|
||
t.Parallel()
|
||
st := newStore(t)
|
||
ctx := context.Background()
|
||
|
||
before, err := EncodeDocuments([]DocumentRef{{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Size: 1234}})
|
||
if err != nil {
|
||
t.Fatalf("encode: %v", err)
|
||
}
|
||
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "读一下", Document: before})
|
||
if err != nil {
|
||
t.Fatalf("append: %v", err)
|
||
}
|
||
|
||
after, err := EncodeDocuments([]DocumentRef{{
|
||
Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Size: 1234,
|
||
Chars: 900, Truncated: true, Status: DocumentStatusExtracted,
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("encode: %v", err)
|
||
}
|
||
if err := st.UpdateMessageDocument(ctx, full.ID, after); err != nil {
|
||
t.Fatalf("UpdateMessageDocument: %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))
|
||
}
|
||
refs, err := DecodeDocuments(msgs[0].Document)
|
||
if err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(refs) != 1 || refs[0].Status != DocumentStatusExtracted || refs[0].Chars != 900 || !refs[0].Truncated {
|
||
t.Fatalf("refs = %+v, want extracted/chars=900/truncated", refs)
|
||
}
|
||
// 其余列保持已存值:回写只碰 document 一列。
|
||
if msgs[0].Content != "读一下" || msgs[0].Role != RoleUser || msgs[0].ID != full.ID || msgs[0].CreatedAt != full.CreatedAt {
|
||
t.Fatalf("msg = %+v, want 除 document 外与落库时一致", msgs[0])
|
||
}
|
||
}
|
||
|
||
// TestUpdateMessageDocumentUnknownID 验证未知 id 返回 ErrMessageNotFound(与
|
||
// UpdateMessage 同一口径),使调用方能区分「那一行不见了」与「写失败」。
|
||
func TestUpdateMessageDocumentUnknownID(t *testing.T) {
|
||
t.Parallel()
|
||
st := newStore(t)
|
||
err := st.UpdateMessageDocument(context.Background(), "no-such-message", "[]")
|
||
if !errors.Is(err, ErrMessageNotFound) {
|
||
t.Fatalf("UpdateMessageDocument(unknown) = %v, want ErrMessageNotFound", err)
|
||
}
|
||
}
|
||
|
||
// 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 := EncodeDocuments([]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)
|
||
}
|
||
}
|
||
}
|