Files
Cyrene_v2/internal/services/gateway/admin_channel_account_online_test.go
T
AskaEth 52dac4575f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 账号在线三态(后端 AccountStatus 可选接口 + 面板第二枚标签 + 契约账目更正)
- known=false ⇒ accountOnline 键**缺席**(未知 ≠ 离线),与既有 connected 语义并列不合并
- 面板:账号在线/账号离线/账号状态未知 三态 + 中性灰 tone + 「这不等于离线」悬停说明
- 测试:gateway 跨层锁「键缺席」(用真 qq_obv11.Adapter)+ 面板 61/40 断言
- 变异:known 当离线 ⇒ 两条测试同时红(已复跑确认)
- Docs/api-client.md:§7.2 那行由未实现改已实现(留表,注明下版移入 §7.1)
2026-10-05 13:29:53 +08:00

236 lines
9.6 KiB
Go
Raw 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 (
"encoding/json"
"io"
"log/slog"
"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/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
)
// 本文件锁定管理列表的**账号在线状态**(`AdminChannelAccount.AccountOnline`,
// 契约 v0.67 §2.4(b))。它守的是 2026-10-05 事故:OneBot 的进程与正向 WS 一直连着,
// 而背后的 QQ 账号已经掉线 —— 面板只报「我们连着」,于是「收不到也发不出」被
// 一片绿盖住。三种情形各一条断言:
// ① 适配器有独立状态面且 known=true ⇒ 在线/离线**如实**浮现;
// ② known=false(刚启动 / 一直探不到)⇒ 键**缺席**(未知),绝不写成 false;
// ③ 适配器没有这条状态面(telegram 之类)⇒ 键**缺席**,同样不是 false。
// onlineStubAdapter 在 rescanStubAdapter 之上多一条**独立状态面**(AccountStatus),
// 正如 *qq_obv11.Adapter 在 get_status 探活之后那样。known 可关,用来复现
// 「刚启动 / 一直探不到」——此时线上必须**没有** accountOnline 键。
type onlineStubAdapter struct {
*rescanStubAdapter
online bool
known bool
}
func (o *onlineStubAdapter) AccountStatus() (bool, bool) { return o.online, o.known }
var _ adapter.PlatformAdapter = (*onlineStubAdapter)(nil)
// namedAccountAdapter 仿照 cmd/cyrene 的 namedAdapter:一个内嵌
// adapter.PlatformAdapter 的未导出包装器,只提升接口的方法。生产路径就是经它注册的,
// 因此网关必须拆开内嵌字段才能抵达账号状态面(与 admin_channels_rescan_test.go 的
// namedRescanAdapter 同形)。
type namedAccountAdapter struct{ adapter.PlatformAdapter }
// *qq_obv11.Adapter 是当前唯一提供该状态面的适配器。编译期锁一下:谁改了这个方法名/
// 签名,管理面就会静默退回「永远未知」(面板一片灰),这里让它**编译不过**。
var _ accountStatusAware = (*qq_obv11.Adapter)(nil)
// TestAdminListChannelsReportsAccountOnline 断言三态都落到线上:确知在线 / 确知离线
// 如实浮现,而「没探到」与「没有这条状态面」都以**键缺席**表示(缺失 ≠ 离线)。
func TestAdminListChannelsReportsAccountOnline(t *testing.T) {
t.Parallel()
caps := capreg.New()
// 三条 qq-obv11 账号:在线、账号掉线(而我们仍连着)、尚未探到。
// 每条都经 namedAccountAdapter 注册,与组合根一致。
for _, tc := range []struct {
acct string
online bool
known bool
}{
{acct: "aa11online", online: true, known: true},
{acct: "bb22down", online: false, known: true},
{acct: "cc33unknown", known: false},
} {
stub := &onlineStubAdapter{
rescanStubAdapter: &rescanStubAdapter{name: "qq-obv11:" + tc.acct, connected: true},
online: tc.online,
known: tc.known,
}
if err := caps.Register(capreg.Capability{
Name: stub.name,
Kind: capreg.KindChannel,
Value: namedAccountAdapter{PlatformAdapter: stub},
}); err != nil {
t.Fatalf("register %s: %v", stub.name, err)
}
}
// telegram 没有独立账号状态面:它的账号**不带** accountOnline 键。
plain := &rescanStubAdapter{name: "telegram:dd44noapi", connected: true}
if err := caps.Register(capreg.Capability{
Name: plain.name,
Kind: capreg.KindChannel,
Value: namedAccountAdapter{PlatformAdapter: plain},
}); err != nil {
t.Fatalf("register %s: %v", plain.name, err)
}
store := &stubChannelConfigStore{
path: t.TempDir() + "/channels.json",
accounts: map[string][]config.ChannelAccount{
"qq-obv11": {
{ID: "aa11online", Config: map[string]any{"access_token": "tk-a"}},
{ID: "bb22down", Config: map[string]any{"access_token": "tk-b"}},
{ID: "cc33unknown", Config: map[string]any{"access_token": "tk-c"}},
},
"telegram": {{ID: "dd44noapi", Config: map[string]any{"bot_token": "tk-d"}}},
},
}
_, 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)
}
got := rawAccounts(t, body)
// ① 确知:在线 true / 账号掉线 false(且 connected 仍为 true —— 两件事并存,
// 这正是事故现场:「我们连着」为真而消息不通)。
online := got["aa11online"]
if !online.present || online.value == nil || !*online.value {
t.Errorf("aa11online accountOnline = %s, want true(确知在线)", online.raw)
}
down := got["bb22down"]
if !down.present || down.value == nil || *down.value {
t.Errorf("bb22down accountOnline = %s, want false(确知离线)", down.raw)
}
if down.connected == nil || !*down.connected {
t.Errorf("bb22down connected = %v, want true —— 连接与账号在线是两件事", down.connected)
}
if online.connected == nil || !*online.connected {
t.Errorf("aa11online connected = %v, want true", online.connected)
}
// ② known=false ⇒ 键**缺席**。写成 false 就是把「没探到」谎报成「账号离线」,
// 与本次事故(界面与事实无关)同属一类。
if unknow := got["cc33unknown"]; unknow.present {
t.Errorf("cc33unknown accountOnline = %s, want the key omitted(未知 ≠ 离线)", unknow.raw)
} else if unknow.connected == nil || !*unknow.connected {
t.Errorf("cc33unknown connected = %v, want true(连接状态不受账号状态面影响)", unknow.connected)
}
// ③ 适配器不实现该接口 ⇒ 键同样缺席,而不是 false。
if na := got["dd44noapi"]; na.present {
t.Errorf("dd44noapi accountOnline = %s, want the key omitted(该平台没有独立状态面)", na.raw)
}
// 整个响应里 accountOnline 只该出现在那两条确知的账号上。
if n := strings.Count(string(body), `"accountOnline"`); n != 2 {
t.Errorf(`accountOnline appears %d times in the list response, want exactly 2: %s`, n, body)
}
// 凭据绝不进列表响应。
if strings.Contains(string(body), "tk-a") || strings.Contains(string(body), "tk-d") {
t.Errorf("list response leaked a token: %s", body)
}
}
// TestAdminListChannelsOmitsAccountOnlineBeforeProbe 跨层闭环:把**真**的
// *qq_obv11.Adapter(从未 Connect、探活从未成功 ⇒ known=false)按组合根注册它的方式
// 注册后,管理列表**不能**出现 accountOnline 键 ——「尚未探到」在面板上必须还是
// 「未知」,不许退化成「账号离线」。
func TestAdminListChannelsOmitsAccountOnlineBeforeProbe(t *testing.T) {
t.Parallel()
// 探活的兜底 logger 会记一条 WARN:测试里换成丢弃器,免得刷屏。
ad := qq_obv11.NewAdapter(
qq_obv11.Config{SelfID: "10001"},
qq_obv11.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
)
if _, known := ad.AccountStatus(); known {
t.Fatal("a freshly built adapter reports known=true, want known=false before any probe")
}
caps := capreg.New()
if err := caps.Register(capreg.Capability{
Name: ad.Name(),
Kind: capreg.KindChannel,
Value: namedAccountAdapter{PlatformAdapter: ad},
}); err != nil {
t.Fatalf("register %s: %v", ad.Name(), err)
}
store := &stubChannelConfigStore{
path: t.TempDir() + "/channels.json",
// 裸适配器名 ⇒ 账号 id 为空(与组合根的单账号路径一致)。
accounts: map[string][]config.ChannelAccount{
ad.Name(): {{Config: map[string]any{"access_token": "tk-real"}}},
},
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
items, body := adminChannelsList(t, ts, tok)
if len(items) != 1 || items[0].Name != ad.Name() {
t.Fatalf("items = %+v, want a single %s row", items, ad.Name())
}
if len(items[0].Accounts) != 1 || items[0].Accounts[0].AccountOnline != nil {
t.Errorf("accounts = %+v, want one account without accountOnline", items[0].Accounts)
}
if strings.Contains(string(body), `"accountOnline"`) {
t.Errorf("accountOnline appears before any probe succeeded: %s", body)
}
}
// rawAccountState 是一条账号的原始线上形状:presence 靠 RawMessage 判(键缺席
// 与 `false` 在类型化解码后长得一样,而这两者的语义恰恰相反)。
type rawAccountState struct {
connected *bool
raw string
present bool
value *bool
}
// rawAccounts 把响应里的账号按 id 铺平成 id → 原始状态,用于区分
// 「键缺席」与「值是 false」。
func rawAccounts(t *testing.T, body []byte) map[string]rawAccountState {
t.Helper()
var rows []struct {
Name string `json:"name"`
Accounts []struct {
ID string `json:"id"`
Connected *bool `json:"connected"`
AccountOnline json.RawMessage `json:"accountOnline"`
} `json:"accounts"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
t.Fatalf("decode list: %v (body=%s)", err, body)
}
out := make(map[string]rawAccountState)
for _, row := range rows {
for _, a := range row.Accounts {
st := rawAccountState{connected: a.Connected, raw: string(a.AccountOnline)}
if len(a.AccountOnline) > 0 {
st.present = true
var v bool
if err := json.Unmarshal(a.AccountOnline, &v); err != nil {
t.Fatalf("decode accountOnline of %s: %v", a.ID, err)
}
st.value = &v
}
out[a.ID] = st
}
}
if len(out) != 4 {
t.Fatalf("accounts = %+v, want all four configured accounts listed", out)
}
return out
}