package gateway import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "sync" "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/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是 S3.5 在 **HTTP 层**的端到端回归(design/24 §6.1、§9.7):一次 `chat` // 带上 3 个附件 → 全部落库 → 回合结束后抽取结论回写 → 历史接口按 `documents` // 数组把引用与结论一起交回客户端。它补齐单测覆盖不到的那一段接线:REST 入口 → // 编排器 → 真实 history 存储 → 历史读端点。 // docStatusRunner 是端到端的假回合驱动器:它把这一回合用户消息里的**每一个** // 文档部件都当成「已抽取」的结论回给网关(正是真实抽取层 reshape 后的形状), // 并记录它实际看到的文档部件数。 type docStatusRunner struct { mu sync.Mutex docs []model.Content calls int } func (r *docStatusRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) { r.mu.Lock() defer r.mu.Unlock() r.calls++ refs := make([]history.DocumentRef, 0, len(r.docs)) for _, m := range msgs { for _, c := range m.Content { if c.Type != model.ContentTypeDocument { continue } r.docs = append(r.docs, c) refs = append(refs, history.DocumentRef{ Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize, Chars: 12, Status: history.DocumentStatusExtracted, }) } } return &loop.Result{Text: "读完了", Documents: refs, Steps: 1, StopReason: "stop"}, nil } // documentCount 返回该回合被驱动时看到的文档部件数。 func (r *docStatusRunner) documentCount() int { r.mu.Lock() defer r.mu.Unlock() return len(r.docs) } // TestChatDocumentsEndToEndCarriesEveryAttachmentAndConclusion 是 S3.5 两个缺口的 // 端到端回归:3 个附件全部进入模型视图、全部落库,且落库的每一行都带回 // status/chars/truncated(此前只有第一个附件落库,且结论永远到不了客户端)。 func TestChatDocumentsEndToEndCarriesEveryAttachmentAndConclusion(t *testing.T) { t.Parallel() dataDir := t.TempDir() jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}} runner := &docStatusRunner{} st := newGatewayHistoryStore(t) srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: runner, History: st}) if err != nil { t.Fatalf("构造网关失败: %v", err) } ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) t.Cleanup(srv.Close) tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour) if err != nil { t.Fatalf("签发 token 失败: %v", err) } names := []string{ storeGatewayDocument(t, dataDir, "第一份", ".txt"), storeGatewayDocument(t, dataDir, "第二份", ".md"), storeGatewayDocument(t, dataDir, "第三份", ".csv"), } refs := make([]chatDocumentRef, 0, len(names)) for _, name := range names { refs = append(refs, chatDocumentRef{Name: name}) } resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{ "sessionId": history.MainSessionID, "text": "这三份都看看", "documents": refs, }, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := runner.documentCount(); got != 3 { t.Fatalf("进入模型可见视图的文档部件 = %d 个, want 3(缺口①:附件不能丢)", got) } resp, body = doReq(t, http.MethodGet, ts.URL+"/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) } var envelope struct { Data []struct { ID string `json:"id"` Role string `json:"role"` Content string `json:"content"` Documents []history.DocumentRef `json:"documents"` } `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { t.Fatalf("unmarshal: %v (body=%s)", err, body) } var userDocs []history.DocumentRef for _, m := range envelope.Data { if m.Role == history.RoleUser && m.Content == "这三份都看看" { userDocs = m.Documents } } if len(userDocs) != 3 { t.Fatalf("历史接口返回的 documents = %+v, want 3 条(缺口①)", userDocs) } for i, name := range names { if userDocs[i].Name != name { t.Fatalf("documents[%d].Name = %q, want %q(顺序必须与发送一致)", i, userDocs[i].Name, name) } if userDocs[i].Status != history.DocumentStatusExtracted || userDocs[i].Chars != 12 { t.Fatalf("documents[%d] = %+v, want extracted/chars=12(缺口②)", i, userDocs[i]) } } } // storeGatewayDocument 按 S1 的内容寻址规则(sha256 前 32 位 + 扩展名)在 // `/uploads/documents` 下落一份文档并返回其存储名(与 chat_documents_test // 的 helper 同规则;这里在 HTTP 端到端用例里独立使用,避免依赖那边的 env)。 func storeGatewayDocument(t *testing.T, dataDir, content, ext string) string { t.Helper() sum := sha256.Sum256([]byte(content)) name := hex.EncodeToString(sum[:])[:documentHashHexLen] + ext if !validDocumentName(name) { t.Fatalf("构造的存储名 %q 不是合法形态", name) } dir := filepath.Join(dataDir, "uploads", documentSubDir) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("创建文档目录失败: %v", err) } if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { t.Fatalf("写入文档失败: %v", err) } return name }