- known=false ⇒ accountOnline 键**缺席**(未知 ≠ 离线),与既有 connected 语义并列不合并 - 面板:账号在线/账号离线/账号状态未知 三态 + 中性灰 tone + 「这不等于离线」悬停说明 - 测试:gateway 跨层锁「键缺席」(用真 qq_obv11.Adapter)+ 面板 61/40 断言 - 变异:known 当离线 ⇒ 两条测试同时红(已复跑确认) - Docs/api-client.md:§7.2 那行由未实现改已实现(留表,注明下版移入 §7.1)
236 lines
9.6 KiB
Go
236 lines
9.6 KiB
Go
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
|
||
}
|