package channelbridge import ( "context" "errors" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件钉住渠道热重载的**注册表同步**(design/04 §3.1):能力注册表里的渠道条目只在 // 启动时写过一次,而热重载重建出来的是同名**新实例**。不同步的话,管理面板(渠道卡片、 // 账号在线状态、open/close 目标解析)读到的仍是保存前的旧实例——卡片永远「未连接」, // 点「连接」驱动的是旧配置里的那把(拨旧 URL)。 // errRegistryDown 表示注册表同步失败(被测代码必须吞掉它,只记日志)。 var errRegistryDown = errors.New("registry unavailable") // errConnectDown 表示新适配器拨号失败。 var errConnectDown = errors.New("dial refused") // recordingChannelRegistry 是 ChannelRegistry 的记录型实现:按注册名保存**接口值**, // 使断言能按指针同一性判定新旧实例(新旧名字相同,比名字必然假绿)。 type recordingChannelRegistry struct { mu sync.Mutex entries map[string]sdk.Channel removed []string replaceErr error removeErr error } func newRecordingRegistry(preset ...sdk.Channel) *recordingChannelRegistry { reg := &recordingChannelRegistry{entries: make(map[string]sdk.Channel, len(preset))} for _, ch := range preset { reg.entries[ch.Name()] = ch } return reg } func (r *recordingChannelRegistry) ReplaceChannel(ch sdk.Channel) error { if r.replaceErr != nil { return r.replaceErr } r.mu.Lock() defer r.mu.Unlock() r.entries[ch.Name()] = ch return nil } func (r *recordingChannelRegistry) RemoveChannel(name string) error { if r.removeErr != nil { return r.removeErr } r.mu.Lock() defer r.mu.Unlock() r.removed = append(r.removed, name) delete(r.entries, name) return nil } // entry 返回当前条目(不存在则 nil),模拟注册表的读侧。 func (r *recordingChannelRegistry) entry(name string) sdk.Channel { r.mu.Lock() defer r.mu.Unlock() return r.entries[name] } // removedNames 返回所有被要求删除的名字。 func (r *recordingChannelRegistry) removedNames() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.removed...) } var _ ChannelRegistry = (*recordingChannelRegistry)(nil) // downAdapter 是 Connect 必失败的适配器,用来钉住「新实例没上线就不动注册表」。 type downAdapter struct { *fakeAdapter err error } func (d *downAdapter) Connect(context.Context) error { return d.err } // newReloadBridgeWithCaps 与 newReloadBridge 同形,额外注入注册表窄端口。 func newReloadBridgeWithCaps(t *testing.T, factory PlatformFactory, caps ChannelRegistry) *Bridge { t.Helper() b, err := New(Options{ Loop: &stubChatter{res: &loop.Result{Text: "hi"}}, Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}}, Owner: &user.OwnerProfile{}, Factory: factory, Channels: caps, }) if err != nil { t.Fatalf("New: %v", err) } t.Cleanup(func() { b.Stop(context.Background()) }) return b } // TestReloadPublishesNewAdapterInstanceToRegistry 是本次修复的主判据:重载后注册表里 // 同名条目必须指向**新实例**,否则面板显示「未连接」、点「连接」驱动旧实例。 func TestReloadPublishesNewAdapterInstanceToRegistry(t *testing.T) { t.Parallel() old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}} factory := &stubFactory{} // 启动注册(wire_channels 的职责)写进注册表的就是这个旧实例,重载要覆盖的正是它。 reg := newRecordingRegistry(sdk.Channel(old)) b := newReloadBridgeWithCaps(t, factory, reg) b.Add(old) b.Start() next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}} factory.adpts = []adapter.PlatformAdapter{next} if err := b.Reload("kook"); err != nil { t.Fatalf("Reload: %v", err) } got := reg.entry("kook:conf0") if got == nil { t.Fatal("重载后注册表里没有 kook:conf0:面板会列出「账号已配置但没有已注册的适配器」") } // 指针同一性判据:新旧实例的 Name() 相同,比名字必然假绿。 if got != sdk.Channel(next) { t.Fatalf("注册表条目 = %#v, want 新实例", got) } if got == sdk.Channel(old) { t.Fatal("注册表仍指向旧实例:卡片会永远显示未连接,点「连接」驱动的是旧 URL 的那把") } if !got.IsConnected() { t.Fatal("注册表里的条目未连接:面板会显示未连接") } if !b.IsConnected("kook:conf0") || old.IsConnected() { t.Fatalf("桥接器应当只让新实例在线:new=%v old=%v", b.IsConnected("kook:conf0"), old.IsConnected()) } } // TestReloadRemovesDroppedAccountFromRegistry:账号被删除(或更名)后,它的注册表条目 // 必须一并消失,否则面板会继续列出一条已经不存在的账号。同时钉住「同名重建的账号绝不 // 被按名字删除」——否则会误删刚写进去的新实例。 func TestReloadRemovesDroppedAccountFromRegistry(t *testing.T) { t.Parallel() dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}} kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}} factory := &stubFactory{} reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept)) b := newReloadBridgeWithCaps(t, factory, reg) b.Add(dropped) b.Add(kept) b.Start() nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}} factory.adpts = []adapter.PlatformAdapter{nextKept} if err := b.Reload("kook"); err != nil { t.Fatalf("Reload: %v", err) } if reg.entry("kook:conf0") != nil { t.Fatal("被移除账号的注册表条目还在:面板会列出一条不存在的账号") } if got := reg.entry("kook:conf1"); got != sdk.Channel(nextKept) { t.Fatalf("保留账号的条目 = %#v, want 新实例", got) } for _, name := range reg.removedNames() { if name == "kook:conf1" { t.Fatal("同名重建的账号被按名字删掉了:会误删刚写回的新实例") } } } // TestReloadWithoutRegistryPortKeepsAdapterRunning:未注入端口时保持旧行为 —— 热重载 // 照常成功、新实例照常上线,只是不同步注册表(不得因为缺端口而 panic 或中断重载)。 func TestReloadWithoutRegistryPortKeepsAdapterRunning(t *testing.T) { t.Parallel() old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}} factory := &stubFactory{} b := newReloadBridgeWithCaps(t, factory, nil) b.Add(old) b.Start() next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}} factory.adpts = []adapter.PlatformAdapter{next} if err := b.Reload("kook"); err != nil { t.Fatalf("未接线注册表不该让 Reload 失败:%v", err) } if !next.IsConnected() || !b.IsConnected("kook:conf0") { t.Fatal("未接线注册表时新适配器仍应照常上线") } } // TestReloadSurvivesRegistryFailures:注册表同步失败只记日志,绝不中断重载、绝不返回 // 错误——面板保存不能被注册表拖垮。 func TestReloadSurvivesRegistryFailures(t *testing.T) { t.Parallel() dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}} kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}} factory := &stubFactory{} reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept)) reg.replaceErr = errRegistryDown reg.removeErr = errRegistryDown b := newReloadBridgeWithCaps(t, factory, reg) b.Add(dropped) b.Add(kept) b.Start() nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}} factory.adpts = []adapter.PlatformAdapter{nextKept} if err := b.Reload("kook"); err != nil { t.Fatalf("注册表同步失败不该让 Reload 失败:%v", err) } if !b.IsConnected("kook:conf1") || !nextKept.IsConnected() { t.Fatal("同步失败时新适配器仍应照常上线") } } // TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect:新实例没上线(拨号失败) // 就不写注册表——换成连不上的实例只会让面板的在线状态更失真;无关账号的条目不受影响。 func TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect(t *testing.T) { t.Parallel() old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}} down := &downAdapter{fakeAdapter: &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "new"}}, err: errConnectDown} factory := &stubFactory{} reg := newRecordingRegistry(sdk.Channel(old)) b := newReloadBridgeWithCaps(t, factory, reg) b.Add(old) b.Start() nextOld := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old2"}} factory.adpts = []adapter.PlatformAdapter{nextOld, down} if err := b.Reload("kook"); err != nil { t.Fatalf("单个适配器连不上不该让 Reload 失败:%v", err) } if reg.entry("kook:conf1") != nil { t.Fatal("没连上的新实例不该写进注册表") } if got := reg.entry("kook:conf0"); got != sdk.Channel(nextOld) { t.Fatalf("已上线账号的条目 = %#v, want 新实例", got) } }