package backthink import ( "context" "errors" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // 本文件锁定切片 P0「她主动找你」的**活跃客户端路由口径**: // // - PushResult.Detail 必须把「送到了几条连接」与「有没有落进主会话」**分开** // 报告 —— 修复前那句「delivered to 2 active client(s)」被读者当成「已推送 // (刷新也在)」,而当时一行都没落库; // - 一次推送**至多让一个 sink 成功**(IM 桥与活跃客户端互斥),这正是 // 「主会话里不会出现双份」的结构性依据:两条路由各自落库一次,而一次推送 // 只走其中一条。 // TestPusher_ActiveDetailReportsPersistence 验证 Detail 分别报告投递数与落库。 func TestPusher_ActiveDetailReportsPersistence(t *testing.T) { t.Parallel() tests := []struct { name string active *mockActive wantPushed bool wantIn string wantNotIn string }{ { name: "投递成功且已落库", active: &mockActive{n: 2, persisted: true}, wantPushed: true, wantIn: "persisted to main session", wantNotIn: "not persisted", }, { name: "投递成功但未落库(故障可见)", active: &mockActive{n: 2, persisted: false}, wantPushed: true, wantIn: "not persisted to main session", }, { name: "无在线客户端但已落库", active: &mockActive{n: 0, persisted: true}, wantPushed: false, wantIn: "recorded in main session", }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() p := newPusher(tc.active, nil, &mockGate{allow: true}, discardLog()) res := p.Push(context.Background(), PushRequest{ ChannelHint: channelWeb, Message: adapter.UnifiedMessage{Content: "记得喝水哦"}, }) if res.Pushed != tc.wantPushed { t.Fatalf("Pushed = %v, want %v (res=%+v)", res.Pushed, tc.wantPushed, res) } if !strings.Contains(res.Detail, tc.wantIn) { t.Fatalf("Detail = %q, want it to contain %q", res.Detail, tc.wantIn) } if tc.wantNotIn != "" && strings.Contains(res.Detail, tc.wantNotIn) { t.Fatalf("Detail = %q, must not contain %q", res.Detail, tc.wantNotIn) } }) } } // TestPusher_OnePushSucceedsOnAtMostOneSink 验证一次推送**至多让一个 sink 成功** // —— 互斥是「主会话里不会双份」的结构性依据。IM 失败后的活跃客户端降级是同一 // 次推送内的兜底:失败的 IM 什么都没写,因此整体仍只有一条记录。 func TestPusher_OnePushSucceedsOnAtMostOneSink(t *testing.T) { t.Parallel() tests := []struct { name string hint string imErr error activeN int wantIMCalls int wantActiveCall int }{ {name: "web 提示只打活跃客户端", hint: channelWeb, activeN: 1, wantIMCalls: 0, wantActiveCall: 1}, {name: "auto 提示 IM 可用时只打 IM", hint: channelAuto, activeN: 2, wantIMCalls: 1, wantActiveCall: 0}, {name: "IM 失败时降级到活跃客户端", hint: channelAuto, imErr: errors.New("no owner IM channel"), activeN: 1, wantIMCalls: 1, wantActiveCall: 1}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() active := &mockActive{n: tc.activeN, persisted: true} im := &mockIMAdapter{err: tc.imErr} p := newPusher(active, im, &mockGate{allow: true}, discardLog()) res := p.Push(context.Background(), PushRequest{ ChannelHint: tc.hint, Message: adapter.UnifiedMessage{Content: "记得喝水哦"}, }) if im.count() != tc.wantIMCalls || active.calls != tc.wantActiveCall { t.Fatalf("sink calls: im=%d (want %d) active=%d (want %d)", im.count(), tc.wantIMCalls, active.calls, tc.wantActiveCall) } // 成功的 sink 至多一个:能落库的路径因此也至多一条。 imOK := tc.imErr == nil && tc.wantIMCalls == 1 activeOK := tc.activeN > 0 && tc.wantActiveCall == 1 if imOK && activeOK { t.Fatalf("both sinks succeeded in one push (res=%+v)", res) } if !imOK && !activeOK && res.Pushed { t.Fatalf("Pushed = true without a successful sink (res=%+v)", res) } }) } }