package channelbridge import ( "context" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // deadSessionAdapter 模拟桥接器运行期间会话死掉的渠道 // ——目前就是 iLink 会话被拒(errcode=-14):Connect // 报告成功,随后读取循环自行返回(没有任何东西取消它), // 从那一刻起 IsConnected 报告 false。 type deadSessionAdapter struct { name string mu sync.Mutex up bool } func (a *deadSessionAdapter) Name() string { return a.name } func (a *deadSessionAdapter) Config() map[string]any { return map[string]any{"bot_token": "tok"} } func (a *deadSessionAdapter) Connect(context.Context) error { a.mu.Lock() a.up = true a.mu.Unlock() return nil } func (a *deadSessionAdapter) Disconnect(context.Context) error { a.mu.Lock() a.up = false a.mu.Unlock() return nil } func (a *deadSessionAdapter) IsConnected() bool { a.mu.Lock() defer a.mu.Unlock() return a.up } func (a *deadSessionAdapter) HealthCheck(context.Context) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) ReadMessages(context.Context, func(adapter.UnifiedMessage)) { // 会话已死:读取循环自行结束,没有任何取消参与其中。 a.mu.Lock() a.up = false a.mu.Unlock() } func (a *deadSessionAdapter) SendMessage(context.Context, adapter.UnifiedResponse) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) SetMessageHandler(adapter.MessageHandler) {} func (a *deadSessionAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() } func (a *deadSessionAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil } // signaledAdapter 是一个 fakeAdapter,其读取循环在返回时发出信号, // 使测试能确定性地等待被替换适配器的退出。 type signaledAdapter struct { *fakeAdapter exited chan struct{} } func (a *signaledAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) { <-ctx.Done() close(a.exited) } // TestReadLoopExitClearsConnectedFlag 是针对「适配器已哑掉、桥接器 // 却仍声称它已连接」的回归测试:读取循环在会话中断、 // 无人取消的情况下结束,因此 IsConnected/ConnectedCount 必须停止 // 报告该渠道存活。 func TestReadLoopExitClearsConnectedFlag(t *testing.T) { t.Parallel() b := newReloadBridge(t, nil) ad := &deadSessionAdapter{name: "weixin-ilink"} b.Add(ad) b.Start() waitDeadline := time.Now().Add(2 * time.Second) for time.Now().Before(waitDeadline) { if !b.IsConnected("weixin-ilink") { if n := b.ConnectedCount(); n != 0 { t.Fatalf("ConnectedCount() = %d, want 0 after the only read loop died", n) } return } time.Sleep(time.Millisecond) } t.Fatal("bridge still reports weixin-ilink as connected after its read loop ended") } // TestReplacedAdapterExitKeepsReplacementConnected 守住另一方向: // 读取循环退出统计绝不能撤销热重载装入的替换适配器。 // 被替换适配器的循环由 Reload 取消(刻意停止),因此它的 // 退出对如今占用该名称的适配器毫无说明力。 func TestReplacedAdapterExitKeepsReplacementConnected(t *testing.T) { t.Parallel() old := &signaledAdapter{ fakeAdapter: &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}, exited: make(chan struct{}), } factory := &stubFactory{adpts: []adapter.PlatformAdapter{ &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}, }} b := newReloadBridge(t, factory) b.Add(old) b.Start() if err := b.Reload("kook"); err != nil { t.Fatalf("Reload: %v", err) } <-old.exited // 退出统计在读取路径返回后立即运行;短促的 // 静置窗口即可捕获,无需依赖特定的交错时序。 settle := time.Now().Add(50 * time.Millisecond) for time.Now().Before(settle) { if !b.IsConnected("kook:conf0") { t.Fatal("the replaced adapter's read-loop exit cleared the replacement's connected flag") } time.Sleep(time.Millisecond) } }