Files
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- 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`
2026-10-04 14:37:37 +08:00

1575 lines
54 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 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)
}
}