Files
Cyrene_v2/internal/services/gateway/proactive_im_notify_test.go
T
AskaEth b36798265a docs(comments): proactive 判据键在 data 内、不在帧壳顶层(安卓核对指出)
4 处注释写「帧顶」而代码放在 data[proactive](+ 测试文件 6 处同源表述)。
安卓端两层都读所以不受影响,但注释与代码相反会误导下一个按注释写的人。
零行为改动(仅注释)。
2026-10-06 21:16:20 +08:00

147 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定切片 P0b:**IM 路由的主动消息也要在「非前台」补一发浏览器通知**。
//
// 背景(P0 如实报回的边界):`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM
// 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上,于是
// **实际最可能发生的路径恰恰不弹**。主人裁定「她说话该不该提醒我,与走哪条路由无关」。
//
// 判据是**结构化**的:主动推送在落库之后追加一条**帧的 `data` 里带 `proactive:true`** 的
// `message_persisted`(gateway.BroadcastProactivePersisted)。普通消息(Web 回合 / IM
// 回复 / IM 镜像)走的仍是 BroadcastPersisted,那条路**永不**放该键 —— 因此普通消息
// 不可能被误判成主动消息。下面分别钉住两端:IM 路由真的发得出这条带判据的帧,而普通
// 广播路径永远发不出它。
// dataFlag 报告帧的 `data` 里是否带 proactive 判据(以及它的值)。
func dataFlag(t *testing.T, ev wsEvent) (any, bool) {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
}
v, has := data["proactive"]
return v, has
}
// frameMessageID 取帧内消息 DTO 的 id。
func frameMessageID(t *testing.T, ev wsEvent) string {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
}
msg, ok := data["message"].(map[string]any)
if !ok {
t.Fatalf("frame message type = %T, want map[string]any", data["message"])
}
id, _ := msg["id"].(string)
return id
}
// TestChannelBridge_SendProactive_EmitsProactiveFlaggedFrame 是切片 P0b 的端到端证据:
// IM 路由的主动消息**既**照旧广播普通镜像帧(P0 起的行为,任何客户端都能渲染),**又**
// 追加一条帧的 `data` 里带 `proactive:true`、并携带落库行权威 id 的帧 —— 前端据此走与
// `proactive_message` 同一套提醒逻辑(非前台补一发通知,两帧同 id ⇒ 去重后一个气泡、
// 一次提醒)。它同时守住 P0 的红线:主会话里仍**恰好一条**(绝无双份)。
func TestChannelBridge_SendProactive_EmitsProactiveFlaggedFrame(t *testing.T) {
env := newEnv(t, nil, false)
store := newHistoryStoreForGateway(t)
env.srv.hub.history = store
owner := newOwnerConn(env.srv.hub, 8)
env.srv.hub.register(owner)
// claims 为 nil 的裸连接是 fail-closed 的,与普通持久化帧同一条角色门。
bare := newFakeConn(env.srv.hub, 8)
env.srv.hub.register(bare)
ownerProfile := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}}
bridge, err := channelbridge.New(channelbridge.Options{
Log: gwTestLogger(),
Loop: stubBridgeChatter{},
Sessions: stubBridgeSessions{},
Owner: ownerProfile,
History: store,
OnPersisted: env.srv.BroadcastPersisted,
})
if err != nil {
t.Fatalf("new bridge: %v", err)
}
bridge.SetOnProactivePersisted(env.srv.BroadcastProactivePersisted)
bridge.Add(&fakeIMAdapter{platform: "telegram"})
if err := bridge.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦")); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
}
// 第一帧:普通镜像帧(P0 起的行为),**不带**判据。
first := readFakeFrame(t, owner)
if first.Type != "message_persisted" {
t.Fatalf("frame 1 type = %q, want message_persisted", first.Type)
}
if v, has := dataFlag(t, first); has {
t.Fatalf("普通镜像帧带上了 proactive=%v,want 该键缺席(判据只属于主动那条)", v)
}
// 第二帧:主动帧 —— 帧的 `data` 里带判据,且消息 id 就是落库行的权威 id。
second := readFakeFrame(t, owner)
if second.Type != "message_persisted" {
t.Fatalf("frame 2 type = %q, want message_persisted(帧类型不变,只多一个结构化键)", second.Type)
}
if v, has := dataFlag(t, second); !has || v != true {
t.Fatalf("主动帧的 proactive 判据 = %v(present=%v),want true", v, has)
}
if got := frameMessageID(t, second); got != rows[0].ID {
t.Fatalf("主动帧的消息 id = %q, want 落库行的权威 id %q(前端去重与对齐都靠它)", got, rows[0].ID)
}
if err := connHasNoFrame(owner); err != nil {
t.Fatalf("owner 连接应恰好收到两帧(普通 + 主动),多出来的:%v", err)
}
if err := connHasNoFrame(bare); err != nil {
t.Fatalf("主动帧只投递给 owner/admin,裸连接不该收到:%v", err)
}
}
// TestServer_BroadcastPersisted_NeverCarriesProactiveFlag 是「不误伤」那一条的后端对照:
// 普通广播路径(Web 回合 / IM 回复 / IM 镜像全走它)**永远**不在帧的 `data` 里放 proactive 判据,
// 因此客户端不可能把普通消息误判成主动消息去弹通知。同一行改走主动那条入口时,判据才
// 出现 —— 两条入口的差别只有这一个键。
func TestServer_BroadcastPersisted_NeverCarriesProactiveFlag(t *testing.T) {
env := newEnv(t, nil, false)
owner := newOwnerConn(env.srv.hub, 8)
env.srv.hub.register(owner)
row := history.Message{
ID: "row-plain", SessionID: history.MainSessionID, Role: history.RoleAssistant,
Content: "普通 IM 回复", Platform: "telegram", AdapterID: "telegram:100", TurnID: "turn-abc", CreatedAt: 1_760_000_000_000,
}
env.srv.BroadcastPersisted(row)
plain := readFakeFrame(t, owner)
if plain.Type != "message_persisted" {
t.Fatalf("frame type = %q, want message_persisted", plain.Type)
}
if v, has := dataFlag(t, plain); has {
t.Fatalf("普通消息的帧的 `data` 里带上了 proactive=%v,want 该键缺席(否则会被误判成主动消息)", v)
}
env.srv.BroadcastProactivePersisted(row)
flagged := readFakeFrame(t, owner)
if v, has := dataFlag(t, flagged); !has || v != true {
t.Fatalf("主动入口的 proactive 判据 = %v(present=%v),want true", v, has)
}
if got := frameMessageID(t, flagged); got != "row-plain" {
t.Fatalf("主动帧的消息 id = %q, want row-plain", got)
}
}