Files
Cyrene_v2/internal/services/gateway/admin_channel_account_status_test.go
T
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
2026-10-05 10:37:56 +08:00

93 lines
3.3 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件锁定管理列表的**按账号**连接状态:多账号平台上,每行连接/断开的门控
// 必须看该账号自己的状态;没有活适配器的账号状态**未知**(字段缺省),
// 而不是被读成「未连接」——把「读不到」画成「没连上」会让主人盲点「断开」。
// TestAdminListChannelsReportsPerAccountConnection 断言每个账号各报自己的状态,
// 且没有活适配器的账号**不带** connected 字段(未知 ≠ 未连接)。
func TestAdminListChannelsReportsPerAccountConnection(t *testing.T) {
t.Parallel()
caps := capreg.New()
live := &spyChannel{name: "kook:3fc8"}
live.connected.Store(true)
down := &spyChannel{name: "kook:conf1"}
for _, ch := range []*spyChannel{live, down} {
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register %s: %v", ch.Name(), err)
}
}
store := &stubChannelConfigStore{
path: t.TempDir() + "/channels.json",
accounts: map[string][]config.ChannelAccount{
"kook": {
{ID: "3fc8", Config: map[string]any{"bot_token": "tk-a"}},
{ID: "conf1", Config: map[string]any{"bot_token": "tk-b"}},
{ID: "conf2", Config: map[string]any{"bot_token": "tk-c"}}, // 无活适配器 ⇒ 状态未知
},
},
}
_, 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("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var items []ChannelSummary
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
t.Fatalf("decode list: %v (body=%s)", err, body)
}
if len(items) != 1 || items[0].Name != "kook" {
t.Fatalf("items = %+v, want a single kook row", items)
}
if len(items[0].Accounts) != 3 {
t.Fatalf("accounts = %+v, want all three configured accounts listed", items[0].Accounts)
}
state := accountConnections(items[0].Accounts)
if got := state["3fc8"]; got == nil || !*got {
t.Fatalf("account 3fc8 connected = %v, want true", got)
}
if got := state["conf1"]; got == nil || *got {
t.Fatalf("account conf1 connected = %v, want false", got)
}
// nil = 字段缺省:没有活适配器 ⇒ 状态未知,绝不能画成「未连接」。
if got := state["conf2"]; got != nil {
t.Fatalf("account conf2 connected = %v, want the field omitted(未知 ≠ 未连接)", *got)
}
// 凭据绝不进列表响应。
if bytes := string(body); containsToken(bytes) {
t.Fatalf("list response leaked a token: %s", bytes)
}
}
// accountConnections 把账号列表映射成 id → 连接状态(字段缺省时值为 nil)。
func accountConnections(accts []AdminChannelAccount) map[string]*bool {
out := make(map[string]*bool, len(accts))
for _, a := range accts {
out[a.ID] = a.Connected
}
return out
}
// containsToken 报告响应体里是否出现了测试用的原始 token 片段。
func containsToken(body string) bool {
for _, tk := range []string{"tk-a", "tk-b", "tk-c"} {
if strings.Contains(body, tk) {
return true
}
}
return false
}