Files
Cyrene_v2/internal/services/gateway/session_document_reason_test.go
T
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

212 lines
8.9 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 gateway
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是契约 v0.63 的 **HTTP 层端到端**回归:`documents[].reason`(「读不到」
// 的原因)从**真实文件**出发,经真实摄取层 → 真实 loop 装配期 → 回合后回写 →
// 历史读接口,一路抵达客户端;且**只在读不到时**出现、**不含任何路径**。
//
// 与 session_document_e2e_test.go(S3.5 的结论回写)的分工:那边用假 runner 验证
// 「每个附件都落库 + 结论都回来」,这里用**真实 loop + 真实摄取层**验证新增的
// reason 字段本身的产生与运送,并额外钉住「extracted 时该键缺席」。
// reasonLeakMarker 是反证用的**独特**目录名:它只出现在真实盘上路径里,
// 因此「响应体里搜不到它」就是「原因文案没有把路径透给客户端」的直接证据。
const reasonLeakMarker = "cyrene-reason-leak-marker"
// reasonStubAdapter 是最小模型适配器:记下请求、回一句文本,使真实 loop 能走完
// 一个回合(与 documents 包的 recordingAdapter 同职责,网关侧独立一份)。
type reasonStubAdapter struct{ reqs []model.Request }
func (a *reasonStubAdapter) Name() string { return "reason-stub" }
func (a *reasonStubAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Model: "stub", Content: "看过了"}, nil
}
func (a *reasonStubAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return a.Chat(ctx, req, model.Options{})
}
// scanPDFBytes 现场拼出一份**合法但无文本层**的 PDF(一页;内容流只画一个矩形,
// 不含任何文本算子)—— 它就是「图片型/扫描件 PDF」在抽取器眼里的样子。
// 不下载、不引入二进制资源(与 documents 包的 buildPDF 同一思路,这里只需这一种
// 形态,故不搬那套完整夹具)。
func scanPDFBytes(t *testing.T) []byte {
t.Helper()
// 页内容流:q 保存状态 → 设填充色 → 画矩形 → Q 恢复状态。没有任何 Tj/TJ。
content := "q 0 0 1 rg 100 100 200 200 re f Q"
objs := []string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
}
var b bytes.Buffer
b.WriteString("%PDF-1.4\n")
offsets := make([]int, len(objs)+1)
for i, body := range objs {
offsets[i+1] = b.Len()
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", i+1, body)
}
xrefAt := b.Len()
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
for i := 1; i <= len(objs); i++ {
fmt.Fprintf(&b, "%010d 00000 n \n", offsets[i])
}
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xrefAt)
return b.Bytes()
}
// documentReasonEnv 搭一套**全真**的装配:真数据目录、真摄取层、真 loop、真
// 历史存储、真 HTTP 服务。返回服务地址、token 与两份文档的存储名。
func documentReasonEnv(t *testing.T) (baseURL, tok, scanName, textName, dataDir string) {
t.Helper()
dataDir = filepath.Join(t.TempDir(), reasonLeakMarker)
scanName = storeGatewayDocument(t, dataDir, string(scanPDFBytes(t)), ".pdf")
textName = storeGatewayDocument(t, dataDir, "季度报告:营收增长 12%。\n", ".txt")
resolver := documents.NewResolver(documents.Config{
DataDir: dataDir,
UploadsDir: filepath.Join(dataDir, "uploads", documentSubDir),
})
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(&reasonStubAdapter{}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
run := loop.New(loop.Options{Agent: ag, DocumentResolver: resolver, MaxRounds: 1})
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: run, History: newGatewayHistoryStore(t)})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
httpSrv := httptest.NewServer(srv.Handler())
t.Cleanup(httpSrv.Close)
t.Cleanup(srv.Close)
tok, err = jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token 失败: %v", err)
}
return httpSrv.URL, tok, scanName, textName, dataDir
}
// TestDocumentReasonReachesMessagesWire 是本契约 ① 的端到端断言:扫描件 PDF ⇒
// `status:"unreadable"` **且**带回原因(文案含「OCR」);纯文本 ⇒ `extracted` 且
// **没有** `reason` 键;整份响应体里搜不到任何路径(反证见 reasonLeakMarker)。
func TestDocumentReasonReachesMessagesWire(t *testing.T) {
t.Parallel()
ts, tok, scanName, textName, _ := documentReasonEnv(t)
resp, body := doReq(t, http.MethodPost, ts+"/api/v1/chat", map[string]any{
"sessionId": history.MainSessionID,
"text": "这两份都看看",
"documents": []chatDocumentRef{{Name: scanName}, {Name: textName}},
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodGet, ts+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("messages status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
docs := userMessageDocuments(t, body, "这两份都看看")
if len(docs) != 2 {
t.Fatalf("历史接口返回的 documents = %+v, want 2 条", docs)
}
got := map[string]map[string]any{}
for _, d := range docs {
name, _ := d["name"].(string)
got[name] = d
}
scan, ok := got[scanName]
if !ok {
t.Fatalf("扫描件的引用不在文档数组里: %+v", docs)
}
if scan["status"] != history.DocumentStatusUnreadable {
t.Fatalf("扫描件 status = %v, want unreadable(这份 PDF 没有文本层)", scan["status"])
}
reason, ok := scan["reason"].(string)
if !ok || reason == "" {
t.Fatalf("扫描件的 documents[].reason 缺失或不是字符串: %+v(契约 v0.63 要求读不到时给出原因)", scan)
}
if !strings.Contains(reason, "OCR") {
t.Fatalf("扫描件 reason = %q, want 含「OCR」(需要 OCR 才是这份 PDF 的真实原因)", reason)
}
text, ok := got[textName]
if !ok {
t.Fatalf("纯文本的引用不在文档数组里: %+v", docs)
}
if text["status"] != history.DocumentStatusExtracted {
t.Fatalf("纯文本 status = %v, want extracted", text["status"])
}
if _, present := text["reason"]; present {
t.Fatalf("extracted 的文档不得带 reason(该键必须缺席): %+v", text)
}
// 反证:原因文案(乃至整份响应)里没有任何路径信息。marker 只存在于盘上路径
// (`<TempDir>/cyrene-reason-leak-marker/uploads/documents/…`),一旦原始错误
// (「open …\cyrene-reason-leak-marker\uploads\documents\….pdf:」)被透出,这里
// 必然命中。**刻意不断言「不含 `/`」**:诚实原因文案本身就用它分隔
// 「图片型/无文本层」,那是文案的一部分,不是路径。
if strings.Contains(string(body), reasonLeakMarker) {
t.Fatalf("响应体里出现了盘上路径片段: %s", body)
}
for _, leak := range []string{reasonLeakMarker, "uploads", string(filepath.Separator) + "documents"} {
if strings.Contains(reason, leak) {
t.Fatalf("reason = %q 含路径片段 %q,原因文案里不得有路径", reason, leak)
}
}
if strings.Contains(reason, scanName) {
t.Fatalf("reason = %q 含存储名之外的引用信息(存储名本身已在同一元素的 name 键里)", reason)
}
}
// userMessageDocuments 取出内容为 content 的那条用户消息的 documents 数组(**按
// 原始 JSON 对象**解,使「键到底在不在」可断言 —— 解进结构体会把「缺席」与
// 「空串」抹成同一个值)。
func userMessageDocuments(t *testing.T, body []byte, content string) []map[string]any {
t.Helper()
var envelope struct {
Data []struct {
Role string `json:"role"`
Content string `json:"content"`
Documents []map[string]any `json:"documents"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("unmarshal messages: %v (body=%s)", err, body)
}
for _, m := range envelope.Data {
if m.Role == history.RoleUser && m.Content == content {
return m.Documents
}
}
t.Fatalf("历史里没有找到内容为 %q 的用户消息 (body=%s)", content, body)
return nil
}