Files
AskaEth 902b6eecfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① 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 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00

328 lines
12 KiB
Go
Raw Permalink 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"
"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)
}
}
}