Files
Cyrene_v2/internal/services/gateway/proactive_test.go
T
AskaEth 84c124f5a9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来
- 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知)

后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库)
  载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」
前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级)
  + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表)
  + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result)
  + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑)
双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次
契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
2026-10-05 16:02:36 +08:00

124 lines
3.9 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/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)
}
if data["content"] != "记得喝水哦" {
t.Fatalf("frame content = %q, want the 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)
}
}