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") } }