Files
AskaEth d81635672e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 热重载把新适配器写回能力注册表
面板保存渠道配置后 Bridge.Reload 只重建了桥接器自己的适配器表,能力注册表里的条目仍指向保存前的旧实例;而管理面的渠道列表、每账号连接状态、open/close 的目标解析全部读注册表 —— 于是卡片永远「未连接」,点「连接」驱动的是旧实例(按旧 URL 拨号)。

生产日志实证:保存后 bridge 侧新实例连上 ws://localhost:3001 且心跳正常,而点连接时被驱动的适配器拨的是旧地址 ws://localhost:10301。

改法:注册表新增 upsert 语义的 ReplaceChannel(覆盖 Value、保留运行期 Policy;Register 遇重名会 ErrDuplicate,热重载必然同名);桥接器经窄端口在适配器上线后同步,账号被移除时删除条目;组合根注入 a.caps 包装。同步失败只记日志、不中断 Reload;未接线时保持旧行为并只 WARN 一次。

验收:go build ./... + go vet 全绿;registry/channelbridge/gateway 三包 test 全绿;主会话亲自变异(摘掉 Policy 沿用 ⇒ TestReplaceChannelSwapsValueAndKeepsPolicy 红)。linux/darwin 交叉编译通过。
2026-10-13 19:21:49 +08:00

157 lines
5.9 KiB
Go
Raw Permalink 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 registry
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住渠道热重载的窄方法 `ReplaceChannel`(upsert,design/04 §3.1):
// 热重载重建出来的适配器**必然同名**,Register 会 ErrDuplicate 把新实例挡在门外,
// 注册表于是继续指向旧实例——管理面板显示「未连接」、点「连接」驱动的是旧实例。
// stubChannel 是名字可指定、连接状态可变的最小渠道(只用于注册/替换的判定)。
type stubChannel struct {
name string
up bool
}
func (s *stubChannel) Name() string { return s.name }
func (s *stubChannel) Connect(context.Context) error { s.up = true; return nil }
func (s *stubChannel) Disconnect(context.Context) error { s.up = false; return nil }
func (s *stubChannel) IsConnected() bool { return s.up }
func (s *stubChannel) HealthCheck(context.Context) error { return nil }
var _ sdk.Channel = (*stubChannel)(nil)
// TestReplaceChannelSwapsValueAndKeepsPolicy 是热重载的主判据:
// - 同名条目换成了**新实例**(调用方随后 Connect/IsConnected 驱动的就是新的那一把);
// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于适配器实现;
// - Kind/Description 与 RegisterChannel 同口径。
func TestReplaceChannelSwapsValueAndKeepsPolicy(t *testing.T) {
const name = "qq-obv11:9616b27ae314253c54419b7541538cdc"
r := New()
old := &stubChannel{name: name, up: true}
if err := r.RegisterChannel(old); err != nil {
t.Fatalf("RegisterChannel: %v", err)
}
if err := r.SetAllowInGroup(name, true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
next := &stubChannel{name: name, up: true}
// 先钉住「为什么必须是替换而不是 Register」:同名注册必被拒。
if err := r.RegisterChannel(next); !errors.Is(err, ErrDuplicate) {
t.Fatalf("同名的 RegisterChannel err = %v, want ErrDuplicate(热重载必然同名)", err)
}
if err := r.ReplaceChannel(next); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get(name)
if err != nil {
t.Fatalf("Get: %v", err)
}
// 判据是接口值同一性:新旧实例的 Name() 完全相同,比名字必然假绿。
if cap.Value != sdk.Channel(next) {
t.Fatalf("替换后 Value = %#v, want 新实例", cap.Value)
}
if cap.Value == sdk.Channel(old) {
t.Fatal("替换后仍指向旧实例:面板会显示未连接、点「连接」驱动旧实例")
}
if !cap.Policy.AllowInGroup {
t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于适配器构造产出,必须沿用")
}
if cap.Kind != KindChannel {
t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindChannel)
}
if cap.Description != name {
t.Fatalf("替换后 Description = %q, want %q(与 RegisterChannel 同口径)", cap.Description, name)
}
}
// TestReplaceChannelRegistersMissingName:名字不存在时按 RegisterChannel 同口径新增
// —— 新加进配置的账号,热重载是它第一次出现在注册表里的时机,没有别处替它注册。
func TestReplaceChannelRegistersMissingName(t *testing.T) {
const name = "kook:conf0"
r := New()
ch := &stubChannel{name: name, up: true}
if err := r.ReplaceChannel(ch); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get(name)
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Value != sdk.Channel(ch) {
t.Fatalf("新增后 Value = %#v, want 该适配器", cap.Value)
}
if cap.Kind != KindChannel || cap.Description != name {
t.Fatalf("新增条目形状 = %q/%q, want %q/%q", cap.Kind, cap.Description, KindChannel, name)
}
// 面板按 Kind 枚举渠道卡片,新条目必须能列出来。
listed := r.List(KindChannel)
if len(listed) != 1 || listed[0].Name != name {
t.Fatalf("List(KindChannel) = %#v, want 仅 %q", listed, name)
}
}
// TestReplaceChannelUpsertHonorsPersistedPolicy 钉住新增分支与 Register 的同口径性:
// DB 是策略的权威载体(裁决 10.4.2),已落盘的策略要覆盖新条目的零值。
func TestReplaceChannelUpsertHonorsPersistedPolicy(t *testing.T) {
r := New()
r.persisted = map[string]ToolPolicy{"kook": {AllowInGroup: true}}
if err := r.ReplaceChannel(&stubChannel{name: "kook"}); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get("kook")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Policy.AllowInGroup {
t.Fatal("新增分支丢掉了已落盘的策略:与 Register 的口径不一致")
}
}
// TestReplaceChannelRefusesNonChannelEntry:同名条目不是渠道时拒绝,绝不悄悄改掉它的 Kind。
func TestReplaceChannelRefusesNonChannelEntry(t *testing.T) {
r := New()
if err := r.Register(Capability{Name: "kook", Kind: KindMCP, Value: struct{}{}}); err != nil {
t.Fatalf("Register: %v", err)
}
err := r.ReplaceChannel(&stubChannel{name: "kook"})
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("err = %v, want ErrInvalidArgument", err)
}
if err == nil || !strings.Contains(err.Error(), string(KindMCP)) {
t.Fatalf("错误消息 %q 里应指出既有类型 %q", err, KindMCP)
}
cap, gerr := r.Get("kook")
if gerr != nil {
t.Fatalf("Get: %v", gerr)
}
if cap.Kind != KindMCP {
t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind)
}
if _, ok := cap.Value.(*stubChannel); ok {
t.Fatal("被拒的替换却改了 Value")
}
}
// TestReplaceChannelRejectsNilAndEmptyName:两个不该被静默吞掉的调用错误。
func TestReplaceChannelRejectsNilAndEmptyName(t *testing.T) {
r := New()
if err := r.ReplaceChannel(nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err)
}
if err := r.ReplaceChannel(&stubChannel{}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成空名 err = %v, want ErrInvalidArgument", err)
}
if got := r.List(""); len(got) != 0 {
t.Fatalf("非法入参不得写进注册表,实际写入 %#v", got)
}
}