Files
Agent-System/internal/server/reply_export_api_test.go
T
weichenandCursor ad9b67d599 feat(artifactvault): close same-tx staging, recover-apply, and GET zero-write gaps
Approve/commit now write FAV staging in the domain transaction, recover-apply materializes scratch collections, and unpublished or GET paths cannot mutate the registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:52:28 +00:00

225 lines
8.4 KiB
Go

package server_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/server"
)
func TestServer_ReplyExportAPI(t *testing.T) {
cfg := &config.Config{
Env: "dev",
HTTPHost: "127.0.0.1",
HTTPPort: 8893,
DataDir: t.TempDir(),
}
svc := bizdev.MustNewService(t, cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
ctx := t.Context()
ctxMgr := svc.ContextManager()
sessID := "sess_export_api_test"
token := loginTestAdmin(t, handler, cfg)
if _, err := ctxMgr.EnsureChatSession(ctx, sessID, 1, 0, "导出测试会话"); err != nil {
t.Fatalf("EnsureChatSession failed: %v", err)
}
// 1. 注入一条完整 assistant 消息
fullContent := "# 临床风险评估报告\n\n根据系统审查,该产品存在以下风险点:\n1. 电气隔离距离不足\n2. 软件未定义看门狗"
asstID, err := ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", fullContent, "", "", nil)
if err != nil {
t.Fatalf("RecordUserMessageWithID failed: %v", err)
}
// 2. 注入一条纯卡片依赖引导消息
cardOnlyContent := "请确认以下操作卡片:"
cardMeta := map[string]any{
"interactive_card": map[string]any{
"card_id": "c1",
"title": "驳回门禁",
},
}
cardOnlyID, err := ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", cardOnlyContent, "", "", cardMeta)
if err != nil {
t.Fatalf("RecordUserMessageWithID cardOnly failed: %v", err)
}
// -------------------------------------------------------------
// 测试 A: 获取 full 消息能力
// -------------------------------------------------------------
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-capability", asstID), nil)
withBearerAuth(req, token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var capRes map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &capRes); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
if capRes["policy"] != "full" {
t.Errorf("expected policy full, got %v", capRes["policy"])
}
formats, ok := capRes["formats"].([]any)
if !ok || len(formats) == 0 {
t.Errorf("expected non-empty formats list")
}
// -------------------------------------------------------------
// 测试 B: 获取 cardOnly 消息能力 (policy = none, reason = CARD_ONLY)
// -------------------------------------------------------------
reqCard := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-capability", cardOnlyID), nil)
withBearerAuth(reqCard, token)
recCard := httptest.NewRecorder()
handler.ServeHTTP(recCard, reqCard)
if recCard.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", recCard.Code, recCard.Body.String())
}
var cardCapRes map[string]any
_ = json.Unmarshal(recCard.Body.Bytes(), &cardCapRes)
if cardCapRes["policy"] != "none" {
t.Errorf("expected cardOnly policy none, got %v", cardCapRes["policy"])
}
if cardCapRes["reason_code"] != "CARD_ONLY" {
t.Errorf("expected CARD_ONLY, got %v", cardCapRes["reason_code"])
}
// -------------------------------------------------------------
// 测试 C: 获取 full 消息正文快照
// -------------------------------------------------------------
reqSrc := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
withBearerAuth(reqSrc, token)
recSrc := httptest.NewRecorder()
handler.ServeHTTP(recSrc, reqSrc)
if recSrc.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", recSrc.Code, recSrc.Body.String())
}
var srcRes map[string]any
if err := json.Unmarshal(recSrc.Body.Bytes(), &srcRes); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
if srcRes["title"] != "临床风险评估报告" {
t.Errorf("expected title '临床风险评估报告', got %v", srcRes["title"])
}
if srcRes["markdown"] != fullContent {
t.Errorf("expected markdown match, got %v", srcRes["markdown"])
}
if recSrc.Header().Get("Cache-Control") != "private, no-store" {
t.Errorf("expected Cache-Control: private, no-store, got %v", recSrc.Header().Get("Cache-Control"))
}
// -------------------------------------------------------------
// 测试 D: cardOnly 请求正文快照应返回 400 MESSAGE_NOT_EXPORTABLE
// -------------------------------------------------------------
reqCardSrc := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", cardOnlyID), nil)
withBearerAuth(reqCardSrc, token)
recCardSrc := httptest.NewRecorder()
handler.ServeHTTP(recCardSrc, reqCardSrc)
if recCardSrc.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recCardSrc.Code)
}
// -------------------------------------------------------------
// 测试 E: 不存在的消息 ID 返回 404
// -------------------------------------------------------------
reqNotFound := httptest.NewRequest(http.MethodGet, "/api/v1/chat/messages/999999/export-source", nil)
withBearerAuth(reqNotFound, token)
recNotFound := httptest.NewRecorder()
handler.ServeHTTP(recNotFound, reqNotFound)
if recNotFound.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recNotFound.Code)
}
// -------------------------------------------------------------
// 测试 F: 冲突的 content_revision 返回 409
// -------------------------------------------------------------
reqConflict := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source?content_revision=999", asstID), nil)
withBearerAuth(reqConflict, token)
recConflict := httptest.NewRecorder()
handler.ServeHTTP(recConflict, reqConflict)
if recConflict.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", recConflict.Code)
}
// 测试 G: 未登录返回 401(严格模式,禁用测试回退)
strictCfg := &config.Config{DataDir: t.TempDir(), AllowInMemory: false}
strictSvc, err := bizdev.NewService(strictCfg)
if err != nil {
t.Fatalf("strict NewService: %v", err)
}
strictHandler := server.NewServer(strictCfg, strictSvc).Routes()
reqUnauth := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
recUnauth := httptest.NewRecorder()
strictHandler.ServeHTTP(recUnauth, reqUnauth)
if recUnauth.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without auth, got %d", recUnauth.Code)
}
// 测试 H: 其他用户无权导出 (IDOR)
reqIDOR := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
withUserIDAuth(reqIDOR, loginTestUser(t, handler, "zhanggong", "123456"))
recIDOR := httptest.NewRecorder()
handler.ServeHTTP(recIDOR, reqIDOR)
if recIDOR.Code != http.StatusForbidden {
t.Fatalf("expected 403 for cross-user export, got %d: %s", recIDOR.Code, recIDOR.Body.String())
}
}
func TestServer_PinExportCopyOnlyAfterCompleted(t *testing.T) {
cfg := &config.Config{
Env: "dev",
HTTPHost: "127.0.0.1",
HTTPPort: 8894,
DataDir: t.TempDir(),
}
svc := bizdev.MustNewService(t, cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
ctx := t.Context()
cm := svc.ContextManager()
sessID := "sess_pin_export"
token := loginTestAdmin(t, handler, cfg)
if _, err := cm.EnsureChatSession(ctx, sessID, 1, 7, "pin 测试"); err != nil {
t.Fatal(err)
}
if _, err := cm.AcquireChatTurn(ctx, sessID, "turn-pin", "hash-pin", "req-pin", 1, 7); err != nil {
t.Fatal(err)
}
msgID, err := cm.RecordUserMessageWithID(ctx, sessID, 1, "assistant", "好的", "", "", nil)
if err != nil {
t.Fatal(err)
}
reqEarly := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/chat/messages/%d/pin-export", msgID), nil)
withBearerAuth(reqEarly, token)
recEarly := httptest.NewRecorder()
handler.ServeHTTP(recEarly, reqEarly)
if recEarly.Code != http.StatusConflict {
t.Fatalf("pin before completed want 409, got %d: %s", recEarly.Code, recEarly.Body.String())
}
if err := cm.CompleteChatTurn(ctx, sessID, "turn-pin", msgID); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/chat/messages/%d/pin-export", msgID), nil)
withBearerAuth(req, token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pin after completed want 200, got %d: %s", rec.Code, rec.Body.String())
}
}