Files
Cyrene_v2/internal/services/gateway/admin_channel_persona_test.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

109 lines
4.4 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// This file covers the admin half of the channel-level role binding (design/22 §2
// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the
// existing read-modify-write + bridge.Reload path, an unknown role is refused
// instead of binding a role that cannot answer, and the list endpoint reports the
// binding back.
// TestAdminSaveChannelConfigPersonaID saves a channel role, asserts it reached
// channels.json and the list wire shape, then clears it.
func TestAdminSaveChannelConfigPersonaID(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "yuki"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load channels warn = %v", warn)
}
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "yuki" {
t.Fatalf("channels.json telegram = %+v, want personaId yuki", accounts["telegram"])
}
if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" {
t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded)
}
// The list endpoint reports the binding so the panel can render it.
var listed []struct {
Name string `json:"name"`
Accounts []struct {
ID string `json:"id"`
PersonaID string `json:"personaId"`
} `json:"accounts"`
}
_, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &listed); err != nil {
t.Fatalf("decode channels list: %v (body=%s)", err, gBody)
}
var accts []struct {
ID string `json:"id"`
PersonaID string `json:"personaId"`
}
for _, entry := range listed {
if entry.Name == "telegram" {
accts = entry.Accounts
}
}
if len(accts) != 1 || accts[0].PersonaID != "yuki" {
t.Fatalf("GET /admin/channels telegram accounts = %+v, want personaId yuki (body=%s)", accts, gBody)
}
// Saving the same account without a personaId unbinds it.
resp, body = doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
map[string]any{"id": "conf0", "token": "bot-tok"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("unbind status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
accounts, _ = runtimeconfig.LoadChannels(path, nil)
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "" {
t.Fatalf("after unbind = %+v, want personaId cleared", accounts["telegram"])
}
}
// TestAdminSaveChannelConfigRejectsUnknownPersonaID keeps the save fail-closed: a
// channel bound to a role the library does not know would silently answer as the
// default role, so it is refused and nothing is written or reloaded.
func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), "personaId") {
t.Fatalf("rejection body = %s, want it to name personaId", body)
}
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none for a refused save", store.reloaded)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("a refused save must not write channels.json (stat err = %v)", err)
}
}