Files
Cyrene_v2/internal/services/gateway/admin_channels_rescan_test.go
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

243 lines
8.7 KiB
Go

package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// rescanStubAdapter 是一个渠道适配器,它同时暴露可选的 rescan
// 能力,正如 *weixin_ilink.Adapter 在 iLink 应答 errcode=-14
// (会话被拒,需要重新扫码)之后那样。
type rescanStubAdapter struct {
name string
connected bool
rescan bool
reason string
connectErr error
}
func (s *rescanStubAdapter) Name() string { return s.name }
func (s *rescanStubAdapter) Config() map[string]any { return map[string]any{"bot_token": "configured"} }
func (s *rescanStubAdapter) Connect(context.Context) error { return s.connectErr }
func (s *rescanStubAdapter) Disconnect(context.Context) error { return nil }
func (s *rescanStubAdapter) IsConnected() bool { return s.connected }
func (s *rescanStubAdapter) HealthCheck(context.Context) error { return nil }
func (s *rescanStubAdapter) SendMessage(context.Context, adapter.UnifiedResponse) error {
return nil
}
func (s *rescanStubAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return nil }
func (s *rescanStubAdapter) SetMessageHandler(adapter.MessageHandler) {}
func (s *rescanStubAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (s *rescanStubAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
return nil, nil
}
func (s *rescanStubAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
}
func (s *rescanStubAdapter) RescanRequired() (bool, string) { return s.rescan, s.reason }
// namedRescanAdapter 仿照 cmd/cyrene 的 namedAdapter:一个内嵌
// adapter.PlatformAdapter 的未导出包装器,它只提升接口的方法。
// 因此网关必须拆开它才能抵达适配器的 rescan 能力。
type namedRescanAdapter struct{ adapter.PlatformAdapter }
var (
_ adapter.PlatformAdapter = (*rescanStubAdapter)(nil)
_ sdk.Channel = namedRescanAdapter{}
)
// TestAdminChannelsReportsNeedsRescan 断言面板能把「需要重新扫码」
// 与单纯的「未连接」区分开:被拒适配器的行携带
// needsRescan + 原因,而健康适配器两者都省略,
// 而不是送出一个编造的值。
func TestAdminChannelsReportsNeedsRescan(t *testing.T) {
t.Parallel()
const reason = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
caps := capreg.New()
// 经包装器注册,与组合根注册它的方式一致。
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
Value: namedRescanAdapter{PlatformAdapter: &rescanStubAdapter{name: "weixin-ilink", rescan: true, reason: reason}},
}); err != nil {
t.Fatalf("register weixin-ilink: %v", err)
}
if err := caps.Register(capreg.Capability{
Name: "telegram",
Kind: capreg.KindChannel,
Value: &rescanStubAdapter{name: "telegram", connected: true},
}); err != nil {
t.Fatalf("register telegram: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
items, body := adminChannelsList(t, ts, tok)
if len(items) != 2 {
t.Fatalf("channels = %+v, want 2 rows", items)
}
byName := map[string]ChannelSummary{}
for _, it := range items {
byName[it.Name] = it
}
wx, ok := byName["weixin-ilink"]
if !ok {
t.Fatalf("no weixin-ilink row in %+v", items)
}
if wx.Connected {
t.Error("weixin-ilink connected = true, want false while iLink rejects the session")
}
if !wx.NeedsRescan {
t.Error("weixin-ilink needsRescan = false, want true (a rejected session must ask for a re-scan)")
}
if wx.LastError != reason {
t.Errorf("weixin-ilink lastError = %q, want %q", wx.LastError, reason)
}
tg, ok := byName["telegram"]
if !ok {
t.Fatalf("no telegram row in %+v", items)
}
if !tg.Connected || tg.NeedsRescan || tg.LastError != "" {
t.Errorf("telegram = %+v, want a connected row without rescan fields", tg)
}
// 健康渠道必须省略这些字段(omitempty),而不是发送默认值:
// 面板要区分「适配器未报告」与「适配器报告了 false」。
var raw []map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil {
t.Fatalf("decode raw items: %v (body=%s)", err, body)
}
for _, item := range raw {
if string(item["name"]) != `"telegram"` {
continue
}
if _, present := item["needsRescan"]; present {
t.Error("telegram row carries needsRescan although the adapter never asked for a re-scan")
}
if _, present := item["lastError"]; present {
t.Error("telegram row carries lastError although there is none")
}
}
// 原因是提示性文本,绝不是凭据。
if strings.Contains(string(body), `"bot_token"`) {
t.Errorf("channel list leaked a bot_token field: %s", body)
}
}
// TestAdminChannelOpenRejectedSessionReturns409 断言 open 端点对重连
// 修不好的会话诚实作答:409 session_rejected 附带重新扫码
// 指引,绝不返回会让面板显示「已连接」的 200。
func TestAdminChannelOpenRejectedSessionReturns409(t *testing.T) {
t.Parallel()
caps := capreg.New()
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
Value: &rescanStubAdapter{
name: "weixin-ilink", rescan: true,
reason: "iLink 返回 errcode=-14(本次会话已被 iLink 拒绝)",
connectErr: weixin_ilink.ErrSessionRejected,
},
}); err != nil {
t.Fatalf("register weixin-ilink: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "weixin-ilink"}, tok)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("open status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
if eobj.Code != "session_rejected" {
t.Fatalf("error code = %q, want session_rejected", eobj.Code)
}
if !strings.Contains(eobj.Message, "扫码") {
t.Fatalf("error message = %q, want the re-scan instruction", eobj.Message)
}
}
// TestAdminChannelsRescanFromRealIlinkAdapter 跨层闭环:一个真正被
// iLink 应答 errcode=-14 的 *weixin_ilink.Adapter,按组合根
// 注册它的方式(包装后)注册,必须在管理渠道列表中
// 浮现 needsRescan + lastError。
func TestAdminChannelsRescanFromRealIlinkAdapter(t *testing.T) {
t.Parallel()
ilog := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/ilink/bot/getupdates" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"errcode":-14}`))
}))
defer ilog.Close()
ad, err := weixin_ilink.New(weixin_ilink.Config{
BaseURL: ilog.URL, BotToken: "tok", PollInterval: time.Millisecond,
}, ilog.Client(), nil)
if err != nil {
t.Fatalf("new weixin adapter: %v", err)
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("connect before rejection: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go ad.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if required, _ := ad.RescanRequired(); required {
break
}
time.Sleep(time.Millisecond)
}
if required, _ := ad.RescanRequired(); !required {
t.Fatal("real adapter never reached the rescan-required state")
}
caps := capreg.New()
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
Value: namedRescanAdapter{PlatformAdapter: ad},
}); err != nil {
t.Fatalf("register weixin-ilink: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
items, _ := adminChannelsList(t, ts, tok)
if len(items) != 1 {
t.Fatalf("channels = %+v, want one weixin-ilink row", items)
}
if items[0].Connected {
t.Error("connected = true although iLink rejected the session")
}
if !items[0].NeedsRescan {
t.Error("needsRescan = false, want true for a rejected iLink session")
}
if items[0].LastError == "" {
t.Error("lastError is empty, want the re-scan reason")
}
}