- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
`clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
(0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
1575 lines
54 KiB
Go
1575 lines
54 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"testing/fstest"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// cfgDefault 构造各测试共用的最小配置。
|
||
func cfgDefault() *config.Config {
|
||
return &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||
}
|
||
|
||
// buildEnv 由显式的 cfg/deps 构造 Server + httptest server,Auth 默认为
|
||
// 真实 JWTService,Bus 默认为真实的进程内 bus。
|
||
func buildEnv(t *testing.T, cfg *config.Config, deps Deps) (*Server, *httptest.Server, *auth.JWTService) {
|
||
t.Helper()
|
||
if cfg == nil {
|
||
cfg = cfgDefault()
|
||
}
|
||
if deps.Auth == nil {
|
||
deps.Auth = auth.New(config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret})
|
||
}
|
||
if deps.Bus == nil {
|
||
deps.Bus = bus.New()
|
||
}
|
||
srv, err := New(cfg, deps)
|
||
if err != nil {
|
||
t.Fatalf("new server: %v", err)
|
||
}
|
||
ts := httptest.NewServer(srv.Handler())
|
||
t.Cleanup(ts.Close)
|
||
jwt, _ := deps.Auth.(*auth.JWTService)
|
||
return srv, ts, jwt
|
||
}
|
||
|
||
// buildServerOnly 构造不带 httptest server 的 Server(用于启动/关闭
|
||
// 生命周期测试)。
|
||
func buildServerOnly(t *testing.T, cfg *config.Config, deps Deps) *Server {
|
||
t.Helper()
|
||
srv, err := New(cfg, deps)
|
||
if err != nil {
|
||
t.Fatalf("new server: %v", err)
|
||
}
|
||
return srv
|
||
}
|
||
|
||
// mustJWT 为测试密钥构造真实 JWTService。
|
||
func mustJWT(t *testing.T) *auth.JWTService {
|
||
t.Helper()
|
||
return auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||
}
|
||
|
||
// signAny 经真实 JWTService 为 sub 签发指定角色的 token。
|
||
func signAny(t *testing.T, jwt *auth.JWTService, sub string, role auth.Role) string {
|
||
t.Helper()
|
||
tok, err := jwt.Sign(sub, role, time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("sign: %v", err)
|
||
}
|
||
return tok
|
||
}
|
||
|
||
// doRaw 用原始(已编码)请求体发起请求。
|
||
func doRaw(t *testing.T, method, url, raw, token string) (*http.Response, []byte) {
|
||
t.Helper()
|
||
req, err := http.NewRequest(method, url, strings.NewReader(raw))
|
||
if err != nil {
|
||
t.Fatalf("new request: %v", err)
|
||
}
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("do request: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
b, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
t.Fatalf("read body: %v", err)
|
||
}
|
||
return resp, b
|
||
}
|
||
|
||
// --- 测试替身(mock) ---------------------------------------------------------
|
||
|
||
// stubAuth 包装真实 JWTService,但可按需让 Sign/Verify 失败。
|
||
type stubAuth struct {
|
||
jwt *auth.JWTService
|
||
signErr error
|
||
verifyErr error
|
||
}
|
||
|
||
func (s *stubAuth) Sign(sub string, role auth.Role, ttl time.Duration) (string, error) {
|
||
if s.signErr != nil {
|
||
return "", s.signErr
|
||
}
|
||
return s.jwt.Sign(sub, role, ttl)
|
||
}
|
||
|
||
func (s *stubAuth) Verify(token string) (*auth.Claims, error) {
|
||
if s.verifyErr != nil {
|
||
return nil, s.verifyErr
|
||
}
|
||
return s.jwt.Verify(token)
|
||
}
|
||
|
||
func (s *stubAuth) Authorize(required ...auth.Role) func(http.Handler) http.Handler {
|
||
return s.jwt.Authorize(required...)
|
||
}
|
||
|
||
// stubCaps 以固定列表和强制出错的 SetPolicy 模拟 CapabilityStore。
|
||
type stubCaps struct {
|
||
list []capreg.Capability
|
||
setErr error
|
||
}
|
||
|
||
func (s *stubCaps) List(kind capreg.Kind) []capreg.Capability {
|
||
if kind == "" {
|
||
return s.list
|
||
}
|
||
out := make([]capreg.Capability, 0)
|
||
for _, cap := range s.list {
|
||
if cap.Kind == kind {
|
||
out = append(out, cap)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *stubCaps) SetPolicy(name string, p capreg.ToolPolicy) error {
|
||
if s.setErr != nil {
|
||
return s.setErr
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// stubSessions 模拟 SessionStore。
|
||
type stubSessions struct {
|
||
list []session.Session
|
||
err error
|
||
}
|
||
|
||
func (s *stubSessions) List(ctx context.Context) ([]session.Session, error) { return s.list, s.err }
|
||
|
||
// stubPersonas 模拟 PersonaStore,返回 gateway 会映射为 PersonaSummary
|
||
// 的领域角色对象。
|
||
type stubPersonas struct {
|
||
list []personareg.Character
|
||
}
|
||
|
||
func (s *stubPersonas) List() []personareg.Character { return s.list }
|
||
|
||
// Persona 满足 PersonaStore;测试不依赖视觉素材,因此它总是报告未找到
|
||
// 角色(空视觉,安全回退)。
|
||
func (s *stubPersonas) Persona(string) (*loader.Persona, bool) { return nil, false }
|
||
|
||
// stubMemories 模拟 MemoryStore,返回 gateway 会映射为 MemorySummary
|
||
// 的领域记忆条目。
|
||
type stubMemories struct {
|
||
list []memory.MemoryItem
|
||
err error
|
||
}
|
||
|
||
func (s *stubMemories) List(ctx context.Context) ([]memory.MemoryItem, error) { return s.list, s.err }
|
||
|
||
// stubRag 模拟 RagStore,返回 gateway 会映射为其 admin 传输结构的
|
||
// 领域文档/命中。
|
||
type stubRag struct {
|
||
docs []knowledge.Doc
|
||
hits []knowledge.Hit
|
||
err error
|
||
}
|
||
|
||
func (s *stubRag) ListDocuments(ctx context.Context) ([]knowledge.Doc, error) { return s.docs, s.err }
|
||
func (s *stubRag) Search(ctx context.Context, query string) ([]knowledge.Hit, error) {
|
||
return s.hits, s.err
|
||
}
|
||
func (s *stubRag) IndexDocument(ctx context.Context, doc knowledge.Doc) error { return s.err }
|
||
func (s *stubRag) DeleteDocument(ctx context.Context, id string) error { return s.err }
|
||
|
||
// stubIoT 模拟 IoTStore。
|
||
type stubIoT struct {
|
||
list []iot.Device
|
||
err error
|
||
}
|
||
|
||
func (s *stubIoT) List(ctx context.Context) ([]iot.Device, error) { return s.list, s.err }
|
||
|
||
// stubAuditor 为 subagent_ask 路由模拟 SubagentAuditor。
|
||
type stubAuditor struct {
|
||
res *orchestration.AuditResult
|
||
err error
|
||
}
|
||
|
||
func (s *stubAuditor) AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error) {
|
||
return s.res, s.err
|
||
}
|
||
|
||
// stubTaskOutputs 为 task_output_query 路由模拟 TaskOutputStore。
|
||
type stubTaskOutputs struct {
|
||
ev task.OutputEvent
|
||
err error
|
||
}
|
||
|
||
func (s *stubTaskOutputs) TaskOutput(ctx context.Context, id string) (task.OutputEvent, error) {
|
||
return s.ev, s.err
|
||
}
|
||
|
||
// stubChannel 实现 sdk.Channel。
|
||
type stubChannel struct{}
|
||
|
||
func (stubChannel) Name() string { return "qq" }
|
||
func (stubChannel) Connect(ctx context.Context) error { return nil }
|
||
func (stubChannel) Disconnect(ctx context.Context) error { return nil }
|
||
func (stubChannel) IsConnected() bool { return true }
|
||
func (stubChannel) HealthCheck(ctx context.Context) error { return nil }
|
||
|
||
var _ sdk.Channel = stubChannel{}
|
||
|
||
// stubBotChannel 模拟一个在 Connect 时经 GET /api/v3/user/me 解析出
|
||
// 机器人身份的在线 kook 适配器(经 Config() 暴露)。
|
||
type stubBotChannel struct{}
|
||
|
||
func (stubBotChannel) Name() string { return "kook:conf0" }
|
||
func (stubBotChannel) Connect(ctx context.Context) error { return nil }
|
||
func (stubBotChannel) Disconnect(ctx context.Context) error { return nil }
|
||
func (stubBotChannel) IsConnected() bool { return true }
|
||
func (stubBotChannel) HealthCheck(ctx context.Context) error { return nil }
|
||
func (stubBotChannel) Config() map[string]any {
|
||
return map[string]any{"bot_id": "bot-7", "bot_username": "bot7", "bot_nickname": "小K"}
|
||
}
|
||
|
||
var _ sdk.Channel = stubBotChannel{}
|
||
|
||
// captureRunner 记录每次 Run 调用看到的 sessionID。
|
||
type captureRunner struct {
|
||
got []string
|
||
res *loop.Result
|
||
err error
|
||
}
|
||
|
||
func (c *captureRunner) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) {
|
||
c.got = append(c.got, sessionID)
|
||
return c.res, c.err
|
||
}
|
||
|
||
// plainWriter 是既未实现 Flusher 也未实现 Hijacker 的裸 ResponseWriter。
|
||
type plainWriter struct{}
|
||
|
||
func (plainWriter) Header() http.Header { return http.Header{} }
|
||
func (plainWriter) Write(b []byte) (int, error) { return len(b), nil }
|
||
func (plainWriter) WriteHeader(code int) {}
|
||
|
||
// --- Server 构造 / 校验 -------------------------------------------------------
|
||
|
||
func TestNewValidation(t *testing.T) {
|
||
t.Parallel()
|
||
jwt := mustJWT(t)
|
||
if _, err := New(nil, Deps{Auth: &stubAuth{jwt: jwt}}); err == nil {
|
||
t.Fatal("nil config must error")
|
||
}
|
||
if _, err := New(&config.Config{}, Deps{}); err == nil {
|
||
t.Fatal("nil auth dependency must error")
|
||
}
|
||
}
|
||
|
||
// --- 登录 --------------------------------------------------------------------
|
||
|
||
func TestLoginBadBody(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
resp, body := doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/auth/login", "{", "")
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestLoginSignError(t *testing.T) {
|
||
t.Parallel()
|
||
jwt := mustJWT(t)
|
||
srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: &stubAuth{jwt: jwt, signErr: errors.New("boom")}})
|
||
_ = srv
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login", map[string]any{"username": "admin", "password": "secret"}, "")
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
// --- 聊天 --------------------------------------------------------------------
|
||
|
||
func TestChatBadBody(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, &stubRunner{}, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/chat", "{", tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestChatStreamMode(t *testing.T) {
|
||
t.Parallel()
|
||
// 接了 loop 的网关:`stream:true` 与 WS 的 chat 帧走同一条直达编排器的
|
||
// 路径(协同 G2),因此它照旧回 200 {"stream":true}。未接 loop 时回
|
||
// 500 chat_unavailable —— 见 TestChatStreamWithoutRunnerIsNotSilentlyAccepted。
|
||
env := newEnv(t, &stubRunner{res: &loop.Result{Text: "ok", StopReason: "stop"}}, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"sessionId": "s1", "text": "hi", "stream": true}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
data := string(decodeBody(t, body)["data"])
|
||
if !strings.Contains(data, `"stream":true`) {
|
||
t.Fatalf("data = %s, want stream true", data)
|
||
}
|
||
}
|
||
|
||
func TestChatRequestPersistsMode(t *testing.T) {
|
||
t.Parallel()
|
||
hist := &fakeHistory{}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{
|
||
Loop: &stubRunner{res: &loop.Result{Text: "回复", StopReason: "stop"}},
|
||
History: hist,
|
||
})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
// REST 请求体携带客户端的行为模式(design/16 §5.1);orchestrator 在
|
||
// 它之下同时持久化用户轮次与助手回复。
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat",
|
||
map[string]any{"sessionId": "web", "text": "改代码", "mode": "code"}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 2 {
|
||
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
||
}
|
||
if recs[0].Role != history.RoleUser || recs[0].Mode != "code" {
|
||
t.Fatalf("user record = %+v, want role=user mode=code", recs[0])
|
||
}
|
||
if recs[1].Role != history.RoleAssistant || recs[1].Mode != "code" {
|
||
t.Fatalf("assistant record = %+v, want role=assistant mode=code", recs[1])
|
||
}
|
||
}
|
||
|
||
func TestChatDefaultsSessionToWeb(t *testing.T) {
|
||
t.Parallel()
|
||
cr := &captureRunner{res: &loop.Result{Text: "ok", Steps: 1, StopReason: "stop"}}
|
||
env := newEnv(t, cr, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"text": "hi"}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if len(cr.got) != 1 || cr.got[0] != "web" {
|
||
t.Fatalf("sessionIDs = %v, want [web]", cr.got)
|
||
}
|
||
}
|
||
|
||
func TestChatRunnerError(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, &stubRunner{err: errors.New("boom")}, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"text": "hi"}, tok)
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
// --- 列表处理器 --------------------------------------------------------------
|
||
|
||
func TestListSessionsNonEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
deps := Deps{Session: &stubSessions{list: []session.Session{{
|
||
Platform: "qq", ChannelID: "c1", SessionID: "s1",
|
||
ActorRole: adapter.RoleOwner, Owner: true, IsGroup: false, MemWritable: true,
|
||
}}}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
for _, path := range []string{"/api/v1/sessions", "/api/v1/admin/sessions"} {
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("%s status = %d, want 200 (body=%s)", path, resp.StatusCode, body)
|
||
}
|
||
var items []struct {
|
||
Platform string `json:"platform"`
|
||
SessionID string `json:"sessionId"`
|
||
Owner bool `json:"owner"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||
t.Fatalf("decode %s: %v", path, err)
|
||
}
|
||
if len(items) != 1 || items[0].Platform != "qq" || items[0].SessionID != "s1" || !items[0].Owner {
|
||
t.Fatalf("%s items = %+v, want single qq/s1 owner", path, items)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestListSessionsUserError(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Session: &stubSessions{err: errors.New("boom")}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestListSessionsAdminError(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Session: &stubSessions{err: errors.New("boom")}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/sessions", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if got := string(decodeBody(t, body)["data"]); got != "[]" {
|
||
t.Fatalf("data = %s, want []", got)
|
||
}
|
||
}
|
||
|
||
func TestListPersonasNonEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
deps := Deps{Personas: &stubPersonas{list: []personareg.Character{{
|
||
ID: "p1", Name: "苏梦", PersonaID: "p1", Default: true, Modes: []string{"chat"},
|
||
}}}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
for _, path := range []string{"/api/v1/personas", "/api/v1/admin/personas"} {
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("%s status = %d, want 200 (body=%s)", path, resp.StatusCode, body)
|
||
}
|
||
var items []struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
IsDefault bool `json:"isDefault"`
|
||
Mode string `json:"mode"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||
t.Fatalf("decode %s: %v", path, err)
|
||
}
|
||
if len(items) != 1 || items[0].Name != "苏梦" || items[0].ID != "p1" || !items[0].IsDefault || items[0].Mode != "chat" {
|
||
t.Fatalf("%s items = %+v, want single 苏梦/p1/default/chat", path, items)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAdminMemoriesNonEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
deps := Deps{Memories: &stubMemories{list: []memory.MemoryItem{{
|
||
ID: "m1", PersonaID: "persona.a", Scope: memory.ScopeProject, Content: "note",
|
||
}}}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var items []struct {
|
||
ID string `json:"id"`
|
||
Scope string `json:"scope"`
|
||
Text string `json:"text"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(items) != 1 || items[0].Scope != "project" || items[0].Text != "note" {
|
||
t.Fatalf("items = %+v, want single project/note", items)
|
||
}
|
||
}
|
||
|
||
func TestAdminMemoriesErrorStillOK(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: &stubMemories{err: errors.New("boom")}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if got := string(decodeBody(t, body)["data"]); got != "[]" {
|
||
t.Fatalf("data = %s, want []", got)
|
||
}
|
||
}
|
||
|
||
// --- Admin:IoT --------------------------------------------------------------
|
||
|
||
func TestAdminIoTNonEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
deps := Deps{IoT: &stubIoT{list: []iot.Device{{
|
||
ID: "d1", Name: "客厅灯", Type: "light",
|
||
State: map[string]any{"power": "on"}, Online: true,
|
||
}}}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/iot", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var items []struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
State map[string]any `json:"state"`
|
||
Online bool `json:"online"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(items) != 1 || items[0].Name != "客厅灯" || items[0].Type != "light" || !items[0].Online {
|
||
t.Fatalf("items = %+v, want single 客厅灯/light/online", items)
|
||
}
|
||
if items[0].State["power"] != "on" {
|
||
t.Fatalf("state = %+v, want power on", items[0].State)
|
||
}
|
||
}
|
||
|
||
func TestAdminIoTError(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{IoT: &stubIoT{err: errors.New("boom")}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/iot", nil, tok)
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
// --- Admin:渠道 -------------------------------------------------------------
|
||
|
||
func TestAdminChannelsFromRegistry(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, true)
|
||
if err := env.caps.RegisterChannel(stubChannel{}); err != nil {
|
||
t.Fatalf("register channel: %v", err)
|
||
}
|
||
// Value 不是 sdk.Channel 的 channel 类能力必须被跳过。
|
||
if err := env.caps.Register(capreg.Capability{Name: "badchan", Kind: capreg.KindChannel, Value: stubTool{}}); err != nil {
|
||
t.Fatalf("register bad channel: %v", err)
|
||
}
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/channels", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var items []struct {
|
||
Name string `json:"name"`
|
||
Connected bool `json:"connected"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(items) != 1 || items[0].Name != "qq" || !items[0].Connected {
|
||
t.Fatalf("items = %+v, want single qq/connected", items)
|
||
}
|
||
}
|
||
|
||
// TestAdminChannels_SurfacesBotIdentity 验证已连接 kook 适配器解析出的
|
||
// 机器人身份(来自 user/me)能到达 GET /admin/channels 的 accounts,
|
||
// 使面板能展示机器人是谁。
|
||
func TestAdminChannels_SurfacesBotIdentity(t *testing.T) {
|
||
t.Parallel()
|
||
caps := capreg.New()
|
||
if err := caps.RegisterChannel(stubBotChannel{}); err != nil {
|
||
t.Fatalf("register channel: %v", err)
|
||
}
|
||
store := &stubChannelConfigStore{
|
||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||
accounts: map[string][]config.ChannelAccount{
|
||
"kook": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}},
|
||
},
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var items []struct {
|
||
Name string `json:"name"`
|
||
Accounts []AdminChannelAccount `json:"accounts"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(items) != 1 || items[0].Name != "kook" {
|
||
t.Fatalf("items = %+v, want single kook row", items)
|
||
}
|
||
if len(items[0].Accounts) != 1 {
|
||
t.Fatalf("accounts = %+v, want one", items[0].Accounts)
|
||
}
|
||
acct := items[0].Accounts[0]
|
||
if acct.BotNickname != "小K" || acct.BotUsername != "bot7" || acct.BotID != "bot-7" {
|
||
t.Errorf("account bot identity = %+v, want 小K/bot7/bot-7", acct)
|
||
}
|
||
}
|
||
|
||
// --- Admin:策略错误 ---------------------------------------------------------
|
||
|
||
func TestAdminSetPolicyUnavailable(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 不接 Registry
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, tok)
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestAdminSetPolicyOtherError(t *testing.T) {
|
||
t.Parallel()
|
||
deps := Deps{Registry: &stubCaps{setErr: errors.New("boom")}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, tok)
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestAdminSetPolicyBadBody(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, true)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", "{", tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestAdminSetPolicyMissingName(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, true)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"allowInGroup": true}, tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
// --- 跨域(CORS) -------------------------------------------------------------
|
||
|
||
func TestCORSAllowAll(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := cfgDefault()
|
||
cfg.AllowedOrigins = []string{"*"}
|
||
srv, ts, _ := buildEnv(t, cfg, Deps{})
|
||
_ = srv
|
||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/health", nil)
|
||
req.Header.Set("Origin", "http://anywhere.example")
|
||
resp := doHandler(t, req)
|
||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
|
||
t.Fatalf("ACAO = %q, want *", got)
|
||
}
|
||
}
|
||
|
||
func TestCORSWhitelistedOrigin(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := cfgDefault()
|
||
cfg.AllowedOrigins = []string{"http://allowed.example"}
|
||
srv, ts, _ := buildEnv(t, cfg, Deps{})
|
||
_ = srv
|
||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/health", nil)
|
||
req.Header.Set("Origin", "http://allowed.example")
|
||
resp := doHandler(t, req)
|
||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://allowed.example" {
|
||
t.Fatalf("ACAO = %q, want origin", got)
|
||
}
|
||
}
|
||
|
||
func TestCORSRejectsUnknownOrigin(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := cfgDefault()
|
||
cfg.AllowedOrigins = []string{"http://allowed.example"}
|
||
srv, ts, _ := buildEnv(t, cfg, Deps{})
|
||
_ = srv
|
||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/health", nil)
|
||
req.Header.Set("Origin", "http://evil.example")
|
||
resp := doHandler(t, req)
|
||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Fatalf("ACAO = %q, want empty for unknown origin", got)
|
||
}
|
||
}
|
||
|
||
func TestCORSPreflight(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := cfgDefault()
|
||
cfg.AllowedOrigins = []string{"http://allowed.example"}
|
||
srv, ts, _ := buildEnv(t, cfg, Deps{})
|
||
_ = srv
|
||
req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/api/v1/chat", nil)
|
||
req.Header.Set("Origin", "http://allowed.example")
|
||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||
resp := doHandler(t, req)
|
||
if resp.StatusCode != http.StatusNoContent {
|
||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||
}
|
||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://allowed.example" {
|
||
t.Fatalf("ACAO = %q, want origin", got)
|
||
}
|
||
}
|
||
|
||
// --- panic 恢复 + statusRecorder ---------------------------------------------
|
||
|
||
func TestRecoverPanicTo500(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
h := env.srv.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
panic("boom")
|
||
}))
|
||
rr := httptest.NewRecorder()
|
||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||
if rr.Code != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500", rr.Code)
|
||
}
|
||
wantErrorShape(t, rr.Body.Bytes())
|
||
}
|
||
|
||
func TestStatusRecorderHijackUnsupported(t *testing.T) {
|
||
t.Parallel()
|
||
rec := &statusRecorder{ResponseWriter: plainWriter{}}
|
||
conn, rw, err := rec.Hijack()
|
||
if err == nil {
|
||
t.Fatal("Hijack on a non-Hijacker must error")
|
||
}
|
||
if conn != nil || rw != nil {
|
||
t.Fatalf("conn/rw = %v/%v, want nil", conn, rw)
|
||
}
|
||
}
|
||
|
||
func TestStatusRecorderFlush(t *testing.T) {
|
||
t.Parallel()
|
||
// 有 Flusher → 转发。
|
||
rr := httptest.NewRecorder()
|
||
(&statusRecorder{ResponseWriter: rr}).Flush()
|
||
if !rr.Flushed {
|
||
t.Fatal("Flush should have been forwarded to the underlying Flusher")
|
||
}
|
||
// 无 Flusher → 空操作,不 panic。
|
||
(&statusRecorder{ResponseWriter: plainWriter{}}).Flush()
|
||
}
|
||
|
||
// --- 单页应用(SPA) ---------------------------------------------------------
|
||
|
||
func TestSPA(t *testing.T) {
|
||
t.Parallel()
|
||
front := fstest.MapFS{
|
||
"index.html": &fstest.MapFile{Data: []byte("<html>root</html>")},
|
||
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
|
||
}
|
||
srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Frontend: front})
|
||
_ = srv
|
||
|
||
// 真实静态资源被正常服务。
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
|
||
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
|
||
t.Fatalf("asset status = %d body = %q, want 200 with js", resp.StatusCode, body)
|
||
}
|
||
// 未知的非 API 路径回退到 index.html。
|
||
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "")
|
||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
||
t.Fatalf("fallback status = %d body = %q, want 200 with index", resp.StatusCode, body)
|
||
}
|
||
// 根路径提供 index.html。
|
||
resp, body = doReq(t, http.MethodGet, ts.URL+"/", nil, "")
|
||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
||
t.Fatalf("root status = %d body = %q, want 200 with index", resp.StatusCode, body)
|
||
}
|
||
// API 路由原样透传。
|
||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "")
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("api status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
}
|
||
|
||
// --- WS:校验 ----------------------------------------------------------------
|
||
|
||
func TestWSRequiresGET(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/ws/chat", nil, tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
}
|
||
|
||
func TestWSInvalidJSON(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
conn := dialWS(t, env.ts.URL, tok)
|
||
defer conn.Close()
|
||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||
if err := conn.WriteMessage(websocket.TextMessage, []byte("{not json")); err != nil {
|
||
t.Fatalf("write invalid json: %v", err)
|
||
}
|
||
ev := readWS(t, conn)
|
||
if ev.Type != "error" {
|
||
t.Fatalf("type = %q, want error (frame=%+v)", ev.Type, ev)
|
||
}
|
||
}
|
||
|
||
func TestWSAuthViaHeader(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/ws/chat"
|
||
hdr := http.Header{}
|
||
hdr.Set("Authorization", "Bearer "+tok)
|
||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, hdr)
|
||
if err != nil {
|
||
t.Fatalf("dial with header: %v (resp=%+v)", err, resp)
|
||
}
|
||
defer conn.Close()
|
||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
|
||
t.Fatalf("write mode_switch: %v", err)
|
||
}
|
||
if got := readWS(t, conn); got.Type != "mode_switch" {
|
||
t.Fatalf("frame type = %q, want mode_switch", got.Type)
|
||
}
|
||
}
|
||
|
||
func TestWSCloseDeregisters(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
conn := dialWS(t, env.ts.URL, tok)
|
||
waitConnCount(t, env.srv, 1)
|
||
conn.Close()
|
||
waitConnCount(t, env.srv, 0)
|
||
}
|
||
|
||
func TestServerCloseShutsHub(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
conn := dialWS(t, env.ts.URL, tok)
|
||
waitConnCount(t, env.srv, 1)
|
||
env.srv.Close()
|
||
waitConnCount(t, env.srv, 0)
|
||
conn.Close()
|
||
}
|
||
|
||
// --- WS:hub 单元测试 --------------------------------------------------------
|
||
|
||
func TestHubStreamChat(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "hi", Steps: 2, StopReason: "stop"}}, nil)
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
hb.streamChat(context.Background(), map[string]any{"sessionId": "s1", "text": "hello"})
|
||
if ev := readFakeFrame(t, c); ev.Type != "stream_chunk" {
|
||
t.Fatalf("first frame type = %q, want stream_chunk (frame=%+v)", ev.Type, ev)
|
||
}
|
||
if ev := readFakeFrame(t, c); ev.Type != "stream_end" {
|
||
t.Fatalf("second frame type = %q, want stream_end (frame=%+v)", ev.Type, ev)
|
||
}
|
||
}
|
||
|
||
func TestHubStreamChatPersistsMode(t *testing.T) {
|
||
t.Parallel()
|
||
hist := &fakeHistory{}
|
||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "hi", StopReason: "stop"}}, nil)
|
||
hb.orchestrator = NewOrchestrator(OrchestratorOptions{
|
||
Run: func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
|
||
return &loop.Result{Text: "hi", StopReason: "stop"}, nil
|
||
},
|
||
History: hist,
|
||
})
|
||
// ws.chat 载荷携带客户端的行为模式(design/16 §5.1);streamChat 把它
|
||
// 串进 orchestrator,后者在它之下持久化用户轮次与助手回复二者。
|
||
hb.streamChat(context.Background(), map[string]any{"sessionId": "web", "text": "改代码", "mode": "code"})
|
||
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for time.Now().Before(deadline) && len(hist.records()) < 2 {
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 2 {
|
||
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
||
}
|
||
if recs[0].Role != history.RoleUser || recs[0].Mode != "code" {
|
||
t.Fatalf("user record = %+v, want role=user mode=code", recs[0])
|
||
}
|
||
if recs[1].Role != history.RoleAssistant || recs[1].Mode != "code" {
|
||
t.Fatalf("assistant record = %+v, want role=assistant mode=code", recs[1])
|
||
}
|
||
}
|
||
|
||
func TestHubBroadcastStream(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(nil, nil)
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
// OnStream→bus→relay 路径上的 text_delta StreamEvent 变成带类型的
|
||
// message.delta 帧,携带增量文本与单调递增的 seq,让旧的
|
||
// stream_chunk 形态不再参与。loop 把会话/轮次身份盖在事件上,
|
||
// hub 必须把它带到帧上,使客户端能把增量路由到活跃会话
|
||
// (design/16 §4.2)。
|
||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0, SessionID: "web", TurnID: "turn-abc"})
|
||
ev := readFakeFrame(t, c)
|
||
if ev.Type != "message.delta" {
|
||
t.Fatalf("frame type = %q, want message.delta (frame=%+v)", ev.Type, ev)
|
||
}
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok || data["delta"] != "Hel" {
|
||
t.Fatalf("frame data = %+v, want delta=Hel", ev.Data)
|
||
}
|
||
if ev.Seq != 1 {
|
||
t.Fatalf("frame seq = %d, want 1", ev.Seq)
|
||
}
|
||
if ev.SessionID != "web" || ev.TurnID != "turn-abc" {
|
||
t.Fatalf("frame scope = session %q turn %q, want session web turn turn-abc", ev.SessionID, ev.TurnID)
|
||
}
|
||
|
||
// tool_call 事件映射为 tool_call.*;非 StreamEvent 的载荷被丢弃。
|
||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindToolCallEnd, ID: "toolu_1", Index: 0})
|
||
ev = readFakeFrame(t, c)
|
||
if ev.Type != "tool_call.end" {
|
||
t.Fatalf("second frame type = %q, want tool_call.end", ev.Type)
|
||
}
|
||
hb.broadcastStream("not-a-stream-event")
|
||
if err := connHasNoFrame(c); err != nil {
|
||
t.Fatalf("unexpected frame from non-StreamEvent payload: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestServerBroadcastCapacityAwaken(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
c := newOwnerConn(env.srv.hub, 32)
|
||
env.srv.hub.register(c)
|
||
|
||
env.srv.BroadcastCapacityAwaken("web", "quiet", "awake", "web_search")
|
||
ev := readFakeFrame(t, c)
|
||
if ev.Type != "capacity_awaken" {
|
||
t.Fatalf("frame type = %q, want capacity_awaken (frame=%+v)", ev.Type, ev)
|
||
}
|
||
if ev.SessionID != "web" {
|
||
t.Fatalf("frame sessionId = %q, want web", ev.SessionID)
|
||
}
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok || data["fromState"] != "quiet" || data["toState"] != "awake" || data["tool"] != "web_search" {
|
||
t.Fatalf("frame data = %+v, want {fromState:quiet,toState:awake,tool:web_search}", ev.Data)
|
||
}
|
||
}
|
||
|
||
// TestServerBroadcastPersistedSplit 验证 BroadcastPersisted 按会话路由事件
|
||
// 类型:Web 主会话及其 Web 子会话广播 `message_persisted`(Web 聊天实时
|
||
// 同步),而隔离的渠道适配器会话(sess-xxx)广播 `channel_persisted`
|
||
// (IM 适配器日志页),因此它被 Web 聊天处理器自然忽略。
|
||
func TestServerBroadcastPersistedSplit(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
c := newOwnerConn(env.srv.hub, 32)
|
||
env.srv.hub.register(c)
|
||
|
||
cases := []struct {
|
||
name string
|
||
sessionID string
|
||
wantType string
|
||
}{
|
||
{name: "main web session", sessionID: history.MainSessionID, wantType: "message_persisted"},
|
||
{name: "web sub session", sessionID: history.MainSessionID + ":abc123", wantType: "message_persisted"},
|
||
{name: "isolated channel session", sessionID: "sess-g", wantType: "channel_persisted"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env.srv.BroadcastPersisted(history.Message{
|
||
ID: "m-" + tc.sessionID, SessionID: tc.sessionID, Role: history.RoleUser,
|
||
Content: "hello", Platform: "telegram", AdapterID: "telegram:abc", CreatedAt: 1000,
|
||
})
|
||
ev := readFakeFrame(t, c)
|
||
if ev.Type != tc.wantType {
|
||
t.Fatalf("frame type = %q, want %q (frame=%+v)", ev.Type, tc.wantType, ev)
|
||
}
|
||
if ev.SessionID != tc.sessionID {
|
||
t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, tc.sessionID)
|
||
}
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data = %+v, want map", ev.Data)
|
||
}
|
||
msg, ok := data["message"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data.message = %+v, want map", data["message"])
|
||
}
|
||
if msg["content"] != "hello" || msg["adapterId"] != "telegram:abc" {
|
||
t.Fatalf("frame message = %+v, want content/hello + adapterId/telegram:abc", msg)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestHubStreamChatIdle(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(nil, nil) // runner 为 nil
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
hb.streamChat(context.Background(), map[string]any{"sessionId": "s1", "text": ""})
|
||
if ev := readFakeFrame(t, c); ev.Type != "stream_end" {
|
||
t.Fatalf("frame type = %q, want stream_end (frame=%+v)", ev.Type, ev)
|
||
}
|
||
}
|
||
|
||
func TestHubStreamChatError(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(&stubRunner{err: errors.New("boom")}, nil)
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
hb.streamChat(context.Background(), map[string]any{"sessionId": "s1", "text": "hi"})
|
||
if ev := readFakeFrame(t, c); ev.Type != "stream_end" {
|
||
t.Fatalf("frame type = %q, want stream_end (frame=%+v)", ev.Type, ev)
|
||
}
|
||
}
|
||
|
||
func TestHubRelayChatBranch(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "hi", Steps: 1, StopReason: "stop"}}, nil)
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
if err := hb.relay(context.Background(), bus.Event{Subject: wsChatSubject, Payload: map[string]any{"sessionId": "s1", "text": "hi"}}); err != nil {
|
||
t.Fatalf("relay: %v", err)
|
||
}
|
||
// relay 在 goroutine 中跑 streamChat;读取时带超时。
|
||
if ev := readFakeFrame(t, c); ev.Type != "stream_chunk" {
|
||
t.Fatalf("frame type = %q, want stream_chunk (frame=%+v)", ev.Type, ev)
|
||
}
|
||
if ev := readFakeFrame(t, c); ev.Type != "stream_end" {
|
||
t.Fatalf("frame type = %q, want stream_end (frame=%+v)", ev.Type, ev)
|
||
}
|
||
}
|
||
|
||
func TestHubRoute(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(nil, bus.New())
|
||
hb.auditor = &stubAuditor{res: &orchestration.AuditResult{
|
||
AgentID: "sub-1", Status: orchestration.StatusRunning, Goal: "do it", Mode: orchestration.AuditFull,
|
||
Messages: []model.Message{model.TextMessage(model.RoleUser, "hi")},
|
||
}}
|
||
hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{
|
||
ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out", Stderr: "err"},
|
||
}}
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
// subagent_ask → subagent_event(真实审计数据,不是硬编码的 pending)。
|
||
c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
|
||
if ev := readFakeFrame(t, c); ev.Type != "subagent_event" {
|
||
t.Fatalf("subagent frame type = %q, want subagent_event", ev.Type)
|
||
}
|
||
// 无 auditor 的 subagent_ask → 明确报错,绝不伪造 pending。
|
||
hb.auditor = nil
|
||
c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
|
||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||
t.Fatalf("no-auditor subagent frame type = %q, want error", ev.Type)
|
||
}
|
||
hb.auditor = &stubAuditor{res: &orchestration.AuditResult{}}
|
||
|
||
// task_output_query → 带真实输出的 task_output。
|
||
c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)})
|
||
if ev := readFakeFrame(t, c); ev.Type != "task_output" {
|
||
t.Fatalf("task frame type = %q, want task_output", ev.Type)
|
||
}
|
||
// 无 task store 的 task_output_query → 明确报错,绝不返回空的 {}。
|
||
hb.taskOutputs = nil
|
||
c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)})
|
||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||
t.Fatalf("no-task store frame type = %q, want error", ev.Type)
|
||
}
|
||
hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out"}}}
|
||
|
||
// 无语音服务的 voice_input → 明确报错,绝不崩溃也不丢弃。
|
||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"x"}`)})
|
||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||
t.Fatalf("no-voice voice_input frame type = %q, want error", ev.Type)
|
||
}
|
||
// 未知类型 → 报错(enqueueJSON)。
|
||
c.route(wsInbound{Type: "nope"})
|
||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||
t.Fatalf("unknown frame type = %q, want error", ev.Type)
|
||
}
|
||
// chat → 发布到 bus(publishPublic 非 nil 路径);不入队任何帧。
|
||
c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"text":"hi"}`)})
|
||
if err := connHasNoFrame(c); err != nil {
|
||
t.Fatalf("chat should not enqueue a frame directly: %v", err)
|
||
}
|
||
}
|
||
|
||
// stubVoice 是供语音路由测试使用的确定性 VoiceService。
|
||
type stubVoice struct {
|
||
sttText string
|
||
sttErr error
|
||
tts voice.Audio
|
||
ttsErr error
|
||
}
|
||
|
||
func (s *stubVoice) STT(_ context.Context, _ voice.Audio) (string, error) { return s.sttText, s.sttErr }
|
||
func (s *stubVoice) TTS(_ context.Context, _ string) (voice.Audio, error) { return s.tts, s.ttsErr }
|
||
func (s *stubVoice) CloneVoice(_ context.Context, _, _ string) (string, error) { return "", nil }
|
||
|
||
func TestHubVoiceInputTurn(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil)
|
||
hb.voice = &stubVoice{sttText: "你好", tts: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}}
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
// "aGVsbG8=" 解码为 "hello" —— 合法的 base64 音频载荷。
|
||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
||
// runVoiceTurn 在 goroutine 中运行;读取时带超时。
|
||
ev := readFakeFrame(t, c)
|
||
if ev.Type != "voice_output" {
|
||
t.Fatalf("voice frame type = %q, want voice_output (frame=%+v)", ev.Type, ev)
|
||
}
|
||
r, ok := ev.Data.(map[string]any)
|
||
if !ok || r["text"] != "你好" {
|
||
t.Fatalf("voice transcript = %+v, want 你好", ev.Data)
|
||
}
|
||
if r["reply"] != "你好" {
|
||
t.Fatalf("voice reply = %+v, want 你好", ev.Data)
|
||
}
|
||
if r["audio"] == "" || r["audio"] == nil {
|
||
t.Fatalf("voice audio missing from reply: %+v", ev.Data)
|
||
}
|
||
}
|
||
|
||
func TestHubBridgeDeviceUpdate(t *testing.T) {
|
||
t.Parallel()
|
||
b := bus.New()
|
||
hb := newTestHub(nil, b)
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
hb.start()
|
||
t.Cleanup(hb.close)
|
||
|
||
if err := b.Publish(context.Background(), "iot.device_update", map[string]any{"message": "灯开了"}); err != nil {
|
||
t.Fatalf("publish: %v", err)
|
||
}
|
||
if ev := readFakeFrame(t, c); ev.Type != "device_update" {
|
||
t.Fatalf("type = %q, want device_update (frame=%+v)", ev.Type, ev)
|
||
}
|
||
}
|
||
|
||
func TestHubBridgeSubagentProgress(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(nil, nil)
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
if err := hb.relay(context.Background(), bus.Event{
|
||
Subject: "cyrene.agent.orchestrator.event",
|
||
Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{
|
||
AgentID: "sub-1", Status: orchestration.StatusDone, RoleID: "engineer", Mode: "chat",
|
||
}},
|
||
}); err != nil {
|
||
t.Fatalf("relay: %v", err)
|
||
}
|
||
if ev := readFakeFrame(t, c); ev.Type != "subagent_event" {
|
||
t.Fatalf("type = %q, want subagent_event (frame=%+v)", ev.Type, ev)
|
||
}
|
||
|
||
// 内部的 ask 主题绝不可暴露给客户端。
|
||
if err := hb.relay(context.Background(), bus.Event{
|
||
Subject: "cyrene.agent.a.b.ask",
|
||
Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{AgentID: "sub-1"}},
|
||
}); err != nil {
|
||
t.Fatalf("relay ask: %v", err)
|
||
}
|
||
if err := connHasNoFrame(c); err != nil {
|
||
t.Fatalf("internal ask must not be broadcast: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPublishPublicNilBus(t *testing.T) {
|
||
t.Parallel()
|
||
hb := newTestHub(nil, nil) // bus 为 nil
|
||
c := newOwnerConn(hb, 32)
|
||
// 不得 panic,也不得入队任何东西。
|
||
c.publishPublic(wsChatSubject, map[string]any{"text": "hi"})
|
||
}
|
||
|
||
func TestRawData(t *testing.T) {
|
||
t.Parallel()
|
||
if rawData(nil) != nil {
|
||
t.Fatal("rawData(nil) should be nil")
|
||
}
|
||
if rawData(json.RawMessage(`{"a":1}`)) == nil {
|
||
t.Fatal("rawData(non-empty) should be non-nil")
|
||
}
|
||
}
|
||
|
||
func TestOriginChecker(t *testing.T) {
|
||
t.Parallel()
|
||
allowed := map[string]bool{"http://ok.example": true}
|
||
checker := originChecker(allowed, false)
|
||
if !checker(reqWithOrigin("")) {
|
||
t.Fatal("empty origin should be allowed")
|
||
}
|
||
if !checker(reqWithOrigin("http://ok.example")) {
|
||
t.Fatal("whitelisted origin should be allowed")
|
||
}
|
||
if checker(reqWithOrigin("http://bad.example")) {
|
||
t.Fatal("unknown origin should be rejected")
|
||
}
|
||
all := originChecker(allowed, true)
|
||
if !all(reqWithOrigin("http://bad.example")) {
|
||
t.Fatal("allow-all should permit any origin")
|
||
}
|
||
}
|
||
|
||
func TestOriginCheckerSameHost(t *testing.T) {
|
||
t.Parallel()
|
||
allowed := map[string]bool{"http://ok.example": true}
|
||
checker := originChecker(allowed, false)
|
||
|
||
// 同主机(精确匹配)—— 生产模式下在网关端口提供内嵌前端。
|
||
req := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||
req.Host = "localhost:10942"
|
||
req.Header.Set("Origin", "http://localhost:10942")
|
||
if !checker(req) {
|
||
t.Fatal("same host origin should be allowed")
|
||
}
|
||
|
||
// 回环别名:host 是 127.0.0.1,但客户端 origin 用的是 localhost。
|
||
req2 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||
req2.Host = "127.0.0.1:10942"
|
||
req2.Header.Set("Origin", "http://localhost:10942")
|
||
if !checker(req2) {
|
||
t.Fatal("localhost<->127.0.0.1 loopback alias should be allowed")
|
||
}
|
||
|
||
// 不同主机仍必须被拒(既非同源,也不在白名单)。
|
||
req3 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||
req3.Host = "evil.example:10942"
|
||
req3.Header.Set("Origin", "http://localhost:10942")
|
||
if checker(req3) {
|
||
t.Fatal("cross-host origin should be rejected")
|
||
}
|
||
|
||
// 同主机、不同端口:仍是同源(同一物理主机),因此放行。
|
||
req4 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||
req4.Host = "localhost:8080"
|
||
req4.Header.Set("Origin", "http://localhost:10942")
|
||
if !checker(req4) {
|
||
t.Fatal("same host (different port) should be allowed — port is not a cross-origin boundary")
|
||
}
|
||
}
|
||
|
||
func TestWSTokenExtraction(t *testing.T) {
|
||
t.Parallel()
|
||
if got := wsToken(reqWithQuery("token", "abc")); got != "abc" {
|
||
t.Fatalf("query token = %q, want abc", got)
|
||
}
|
||
r := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||
r.Header.Set("Authorization", "Bearer xyz")
|
||
if got := wsToken(r); got != "xyz" {
|
||
t.Fatalf("header token = %q, want xyz", got)
|
||
}
|
||
if got := wsToken(httptest.NewRequest(http.MethodGet, "/ws/chat", nil)); got != "" {
|
||
t.Fatalf("empty token = %q, want empty", got)
|
||
}
|
||
}
|
||
|
||
// --- Start / ListenAndServe 生命周期 ------------------------------------------
|
||
|
||
func TestStartAndShutdown(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := cfgDefault()
|
||
cfg.Server = config.ServerConfig{Host: "127.0.0.1", Port: 0}
|
||
srv := buildServerOnly(t, cfg, Deps{Auth: mustJWT(t)})
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
errCh, err := srv.Start(ctx)
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
cancel()
|
||
select {
|
||
case err := <-errCh:
|
||
if err != nil {
|
||
t.Fatalf("listen/serve returned error: %v", err)
|
||
}
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("server did not shut down on cancel")
|
||
}
|
||
}
|
||
|
||
// --- 辅助函数 ----------------------------------------------------------------
|
||
|
||
func doHandler(t *testing.T, req *http.Request) *http.Response {
|
||
t.Helper()
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("do request: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
_, _ = io.ReadAll(resp.Body)
|
||
return resp
|
||
}
|
||
|
||
func dialWS(t *testing.T, baseURL, token string) *websocket.Conn {
|
||
t.Helper()
|
||
wsURL := "ws" + strings.TrimPrefix(baseURL, "http") + "/ws/chat?token=" + token
|
||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||
if err != nil {
|
||
t.Fatalf("dial ws: %v", err)
|
||
}
|
||
return conn
|
||
}
|
||
|
||
func waitConnCount(t *testing.T, srv *Server, want int) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if srv.hub.connCount() == want {
|
||
return
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
t.Fatalf("conn count = %d, want %d", srv.hub.connCount(), want)
|
||
}
|
||
|
||
func newTestHub(runner Chatter, b bus.Bus) *hub {
|
||
return &hub{
|
||
conns: make(map[*wsConn]struct{}),
|
||
bus: b,
|
||
runner: runner,
|
||
log: sourcedLog(nil),
|
||
}
|
||
}
|
||
|
||
func newFakeConn(hb *hub, buf int) *wsConn {
|
||
return &wsConn{
|
||
send: make(chan []byte, buf),
|
||
done: make(chan struct{}),
|
||
h: hb,
|
||
}
|
||
}
|
||
|
||
// newOwnerConn 是携带 owner claims 的假连接:design/11 §7.1 帧门把每个
|
||
// 内容帧(message.*、thinking.*、tool_*、graph_update、mood、持久化消息
|
||
// 帧,……)只发给 owner/admin,因此读取其中之一的 hub 单元测试必须以
|
||
// 被放行的角色连接。claims 为 nil 的连接(newFakeConn)是 fail-closed
|
||
// 的裸连接 —— 仅状态信号总能到达。
|
||
func newOwnerConn(hb *hub, buf int) *wsConn {
|
||
c := newFakeConn(hb, buf)
|
||
c.claims = &auth.Claims{Role: auth.RoleOwner}
|
||
return c
|
||
}
|
||
|
||
func readFakeFrame(t *testing.T, c *wsConn) wsEvent {
|
||
t.Helper()
|
||
select {
|
||
case b := <-c.send:
|
||
var ev wsEvent
|
||
if err := json.Unmarshal(b, &ev); err != nil {
|
||
t.Fatalf("decode frame %s: %v", b, err)
|
||
}
|
||
return ev
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("timed out waiting for a frame")
|
||
return wsEvent{}
|
||
}
|
||
}
|
||
|
||
func connHasNoFrame(c *wsConn) error {
|
||
select {
|
||
case b := <-c.send:
|
||
return errors.New("unexpected frame: " + string(b))
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func reqWithOrigin(origin string) *http.Request {
|
||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
r.Header.Set("Origin", origin)
|
||
return r
|
||
}
|
||
|
||
func reqWithQuery(k, v string) *http.Request {
|
||
return httptest.NewRequest(http.MethodGet, "/ws/chat?"+k+"="+v, nil)
|
||
}
|
||
|
||
// --- RAG 知识库 --------------------------------------------------------------
|
||
|
||
func TestAdminRagListsAndSearches(t *testing.T) {
|
||
t.Parallel()
|
||
docs := []knowledge.Doc{{ID: "p1", Name: "开发时间线.md", Desc: "milestones", Chunks: 3, Embedded: true}}
|
||
hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Chapter: "M4", Text: "RAG 检索测试", Score: 1.0}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{docs: docs, hits: hits}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
// 仅列表。
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var list struct {
|
||
Documents []AdminRagDoc `json:"documents"`
|
||
Hits []AdminRagHit `json:"hits"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
|
||
t.Fatalf("decode list: %v (body=%s)", err, body)
|
||
}
|
||
if len(list.Documents) != 1 || list.Documents[0].Name != "开发时间线.md" {
|
||
t.Fatalf("documents = %+v, want one 开发时间线.md", list.Documents)
|
||
}
|
||
if list.Hits != nil {
|
||
t.Fatalf("hits = %+v, want omitted without query", list.Hits)
|
||
}
|
||
|
||
// 列表 + 经 ?query= 检索。
|
||
resp2, body2 := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag?query=rag", nil, tok)
|
||
if resp2.StatusCode != http.StatusOK {
|
||
t.Fatalf("search status = %d, want 200 (body=%s)", resp2.StatusCode, body2)
|
||
}
|
||
var search struct {
|
||
Documents []AdminRagDoc `json:"documents"`
|
||
Hits []AdminRagHit `json:"hits"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body2)["data"], &search); err != nil {
|
||
t.Fatalf("decode search: %v (body=%s)", err, body2)
|
||
}
|
||
if len(search.Hits) != 1 || search.Hits[0].DocName != "开发时间线.md" {
|
||
t.Fatalf("hits = %+v, want one hit", search.Hits)
|
||
}
|
||
}
|
||
|
||
func TestAdminRagQueryPost(t *testing.T) {
|
||
t.Parallel()
|
||
hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Text: "RAG 检索测试", Score: 1.0}}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{hits: hits}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var res struct {
|
||
Query string `json:"query"`
|
||
Hits []AdminRagHit `json:"hits"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||
}
|
||
if res.Query != "rag" || len(res.Hits) != 1 {
|
||
t.Fatalf("response = %+v, want query=rag with 1 hit", res)
|
||
}
|
||
}
|
||
|
||
func TestAdminRagEmptyWhenUnwired(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
// 列表降级为空(绝不 500)。
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var list struct {
|
||
Documents []AdminRagDoc `json:"documents"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
|
||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||
}
|
||
if len(list.Documents) != 0 {
|
||
t.Fatalf("documents = %+v, want empty", list.Documents)
|
||
}
|
||
|
||
// 检索明确返回 501(后端未实现)。
|
||
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
|
||
if resp2.StatusCode != http.StatusNotImplemented {
|
||
t.Fatalf("query status = %d, want 501 (body=%s)", resp2.StatusCode, body2)
|
||
}
|
||
wantErrorShape(t, body2)
|
||
if !strings.Contains(string(body2), "not_implemented") {
|
||
t.Fatalf("error body = %s, want not_implemented code", body2)
|
||
}
|
||
}
|
||
|
||
// TestAdminRagDeleteUnsupportedStore501 断言无法删除的存储会被诚实地以
|
||
// 501 应答,而不是伪造成功。
|
||
func TestAdminRagDeleteUnsupportedStore501(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{err: knowledge.ErrWriteUnsupported}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": "p1"}, tok)
|
||
if resp.StatusCode != http.StatusNotImplemented {
|
||
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
if !strings.Contains(string(body), "not_implemented") {
|
||
t.Fatalf("error body = %s, want not_implemented code", body)
|
||
}
|
||
}
|
||
|
||
func TestAdminRagDeleteRequiresID(t *testing.T) {
|
||
t.Parallel()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{}})
|
||
_ = srv
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": " "}, tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||
}
|
||
}
|