Files
AskaEth 1d3965aa80 fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)
M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
2026-10-24 19:40:30 +08:00

127 lines
4.2 KiB
Go
Raw Permalink 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/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// TestHub_SendProactive_OwnerAndAdmin 验证主动推送恰好抵达
// owner/admin 连接并返回投递计数;viewer 或未认证
// (nil claims)连接绝不收到它。
func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
owner := newFakeConn(hb, 8)
owner.claims = &auth.Claims{Role: auth.RoleOwner}
admin := newFakeConn(hb, 8)
admin.claims = &auth.Claims{Role: auth.RoleAdmin}
viewer := newFakeConn(hb, 8)
viewer.claims = &auth.Claims{Role: auth.RoleViewer}
bare := newFakeConn(hb, 8) // claims 为 nil:未认证
for _, c := range []*wsConn{owner, admin, viewer, bare} {
hb.register(c)
}
msg := adapter.UnifiedMessage{
Platform: "cyrene-backthink",
ChannelType: adapter.ChannelDirect,
Content: "记得喝水哦",
ContentType: adapter.ContentText,
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
ChannelID: "kook:123",
SenderID: "uid-999",
MessageID: "m-42",
}
n, err := hb.SendProactive(context.Background(), msg)
if err != nil {
t.Fatalf("SendProactive err = %v", err)
}
if n.Connections != 2 {
t.Fatalf("count = %d, want 2 (owner+admin; viewer and bare excluded)", n.Connections)
}
for _, c := range []*wsConn{owner, admin} {
ev := readFakeFrame(t, c)
if ev.Type != "proactive_message" {
t.Fatalf("frame type = %q, want proactive_message (frame=%+v)", ev.Type, ev)
}
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
}
// 正文经共享规整器(turnreply.NormalizeParagraphs):纯散文也会被包成
// 一个 `<p>` —— 这条路由与 Web 回合 / IM 出站 / IM 侧主动推送**同源**,
// 未包裹的裸文本同样会包上(M25 修的就是本路由曾绕过它)。
if data["content"] != "<p>记得喝水哦</p>" {
t.Fatalf("frame content = %q, want the normalized proactive text", data["content"])
}
if data["sender"] != "Cyrene" {
t.Fatalf("frame sender = %q, want Cyrene", data["sender"])
}
if data["platform"] != "cyrene-backthink" {
t.Fatalf("frame platform = %q, want cyrene-backthink", data["platform"])
}
}
for _, c := range []*wsConn{viewer, bare} {
if err := connHasNoFrame(c); err != nil {
t.Fatalf("unexpected frame for a non-owner/admin conn: %v", err)
}
}
}
// TestHub_SendProactive_NoOnlineOwner 验证没有存活 owner/admin 连接时
// 计数为零(无错误),使调用方回退到 IM bridge。
func TestHub_SendProactive_NoOnlineOwner(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
viewer := newFakeConn(hb, 8)
viewer.claims = &auth.Claims{Role: auth.RoleViewer}
hb.register(viewer)
n, err := hb.SendProactive(context.Background(), adapter.UnifiedMessage{Content: "hi"})
if err != nil {
t.Fatalf("SendProactive err = %v", err)
}
if n.Connections != 0 {
t.Fatalf("count = %d, want 0", n.Connections)
}
}
// TestHub_SendProactive_CancelledContext 验证被取消的 context 拒绝该
// 推送(fail closed)并返回被 wrap 的错误。
func TestHub_SendProactive_CancelledContext(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := hb.SendProactive(ctx, adapter.UnifiedMessage{Content: "hi"}); err == nil {
t.Fatalf("expected a context error, got nil")
}
}
// TestServer_SendProactive_DelegatesToHub 验证 Server.SendProactive 镜像
// hub,使组合根能把网关自身交给汇聚点设置器。
func TestServer_SendProactive_DelegatesToHub(t *testing.T) {
t.Parallel()
env := newEnv(t, nil, false)
c := newFakeConn(env.srv.hub, 8)
c.claims = &auth.Claims{Role: auth.RoleOwner}
env.srv.hub.register(c)
n, err := env.srv.SendProactive(context.Background(), adapter.UnifiedMessage{Content: "hi"})
if err != nil {
t.Fatalf("SendProactive err = %v", err)
}
if n.Connections != 1 {
t.Fatalf("count = %d, want 1", n.Connections)
}
ev := readFakeFrame(t, c)
if ev.Type != "proactive_message" {
t.Fatalf("frame type = %q, want proactive_message", ev.Type)
}
}