Files
Cyrene_v2/internal/services/channelbridge/readloop_test.go
T
AskaEth a51aadeef4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。
- 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected
- 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留
- 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期)
- Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
2026-10-03 18:26:27 +08:00

129 lines
4.2 KiB
Go

package channelbridge
import (
"context"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// deadSessionAdapter mimics a channel whose session dies while the bridge is
// running — today the iLink session rejection (errcode=-14): Connect
// reports success, the read loop then returns on its own (nothing cancelled it)
// and IsConnected reports false from that point on.
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)) {
// The session died: the read loop ends by itself, no cancellation involved.
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 is a fakeAdapter whose read loop reports when it has returned,
// so a test can wait for a replaced adapter's exit deterministically.
type signaledAdapter struct {
*fakeAdapter
exited chan struct{}
}
func (a *signaledAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
close(a.exited)
}
// TestReadLoopExitClearsConnectedFlag is the regression for "the adapter goes mute
// while the bridge still claims it is connected": the read loop ends with the
// session down and nothing cancelled it, so IsConnected/ConnectedCount must stop
// reporting a live channel.
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 guards the other direction: the
// read-loop exit accounting must not undo a replacement installed by a hot reload.
// The replaced adapter's loop is cancelled by Reload (a deliberate stop), so its
// exit says nothing about the adapter that now owns the name.
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
// The exit accounting runs immediately after the read path returns; a short
// settle window catches it without relying on a specific interleaving.
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)
}
}