面板保存渠道配置后 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 交叉编译通过。
157 lines
5.9 KiB
Go
157 lines
5.9 KiB
Go
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)
|
||
}
|
||
}
|