Files
Cyrene_v2/internal/services/channelbridge/notice_test.go
T
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00

381 lines
13 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 channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件覆盖 design/12 §6 策略一补充的「系统通知传入前审查」:归一化 / 分类 /
// fail-safe 三条路径,以及它与审查模型(策略四同一注入点)的复用点。
// fail-safe 的保守方向写死在 noticeFailSafeProceeds(false = 静默),变异验证即把它
// 改成 true —— 下面 TestNoticeReviewFailureFailsSafeSilent、
// TestNoticeReviewerMissingFailsSafeSilent 与 TestPreEntryReviewFailSafeIsSilent
// 会变红。
// noticeAdapter 是最小的 PlatformAdapter + SystemNoticeNormalizer:
// 它按夹具给定的结论回答归一化,并记录收到的原始消息与调用次数。
type noticeAdapter struct {
fakeAdapter
notice adapter.SystemNotice
ok bool
panics bool
calls int
rawSeen []byte
}
func (n *noticeAdapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) {
n.calls++
n.rawSeen = msg.RawData
if n.panics {
panic("malformed platform notice")
}
return n.notice, n.ok
}
// plainAdapter 是**未实现**归一化 seam 的适配器(可选接口缺席时的 fail-safe 路径)。
type plainAdapter struct{ fakeAdapter }
// groupNoticeMessage 是一条带结构化系统事件标记的群通知:ContentType=ContentAction
// 是适配器给出的「非聊天动作信号」标记(判「是否系统」只看这个元数据,绝不看正文)。
func groupNoticeMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "qq-obv11",
ChannelID: "g1",
ChannelType: adapter.ChannelGroup,
GroupID: "g1",
GroupName: "群一",
SenderID: "system",
SenderName: "system",
Sender: adapter.Actor{Role: adapter.RoleSystem, UserID: "system"},
ContentType: adapter.ContentAction,
RawData: []byte(`{"post_type":"notice","notice_type":"group_upload"}`),
}
}
// noticeFixture 组装一条入站通知所需的桥接器:受限群会话 + 捕获历史 +
// 记录调用的循环。review 为 nil 表示未装配审查模型。
func noticeFixture(t *testing.T, ad adapter.PlatformAdapter, review adapter.Reviewer) (*Bridge, *stubChatter, *stubSessions, *captureHistory) {
t.Helper()
chat := &stubChatter{res: nil}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, History: hist,
Owner: testOwnerProfile("qq-obv11"),
Review: review,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, chat, sess, hist
}
// systemLines 返回循环收到的系统角色消息文本。
func systemLines(msgs []model.Message) []string {
var out []string
for _, m := range msgs {
if m.Role == model.RoleSystem {
for _, c := range m.Content {
out = append(out, c.Text)
}
}
}
return out
}
// TestNoticeIgnorableRegisteredOnly 覆盖「可忽略 → 仅登记,不喂 LLM,不驱动 loop」。
func TestNoticeIgnorableRegisteredOnly(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeJoin, SafeText: "有成员加入了群聊", Ignorable: true,
}, ok: true}
b, chat, _, hist := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.SessionID())
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("登记的记录数 = %d,want 1(仅登记)", len(recs))
}
if recs[0].Content != "有成员加入了群聊" || recs[0].SessionID != "sess-g1" {
t.Errorf("登记内容 = %+v", recs[0])
}
}
// TestNoticeHighValueEscalatesWithoutReview 覆盖「高价值 → 升级真会话」:
// 它越过审查模型(结构判定优先),进入真会话,并以系统通知行**限定注入**。
func TestNoticeHighValueEscalatesWithoutReview(t *testing.T) {
t.Parallel()
reviewed := 0
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
reviewed++
return nil, nil
}
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeLeave, SafeText: "系统通知:她(机器人账号)被移出了群聊", HighValue: true,
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if reviewed != 0 {
t.Errorf("高价值通知不该走剔噪审查,调用次数 = %d", reviewed)
}
if chat.SessionID() != "sess-g1" {
t.Fatalf("高价值通知没进真会话:session=%q", chat.SessionID())
}
lines := systemLines(chat.Messages())
if len(lines) != 1 || !strings.HasPrefix(lines[0], noticeSystemPrefix) {
t.Fatalf("限定注入的系统行 = %v", lines)
}
for _, m := range chat.Messages() {
if m.Role == model.RoleUser {
t.Errorf("系统通知被当成用户消息注入:%v", m.Content)
}
}
}
// TestNoticeLimitedInjectReusesReviewModel 覆盖「其余 → 提取安全文本、剥离异常字段后
// 限定注入」,并证明它复用的是 design/12 §6 策略四的**同一个**审查模型注入点:
// 审查器只看得到剥离后的安全文本,且原始载荷与附件已被剥离。
func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) {
t.Parallel()
var probe adapter.UnifiedMessage
review := func(_ context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
if len(msgs) != 1 {
t.Fatalf("审查器应收到单条消息,收到 %d 条", len(msgs))
}
probe = msgs[0]
return []int{0}, nil
}
dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07超长" + strings.Repeat("啊", 300)
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: dirty}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "sess-g1" {
t.Fatalf("审查保留的通知没进上下文:session=%q", chat.SessionID())
}
if probe.RawData != nil || len(probe.Media) != 0 {
t.Errorf("审查器看到了未剥离字段:raw=%q media=%d", probe.RawData, len(probe.Media))
}
if strings.ContainsAny(probe.Content, "\u200b\x1b\x07\n") {
t.Errorf("审查器看到的安全文本未剥离异常字符:%q", probe.Content)
}
if len([]rune(probe.Content)) > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("安全文本未截断:%d runes", len([]rune(probe.Content)))
}
lines := systemLines(chat.Messages())
if len(lines) != 1 || !strings.Contains(lines[0], "群文件上传") {
t.Fatalf("限定注入的系统行 = %v", lines)
}
if got := len([]rune(strings.TrimPrefix(lines[0], noticeSystemPrefix))); got > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("注入文本未截断:%d runes", got)
}
}
// TestNoticeReviewDroppedStaysSilent 覆盖审查模型判「没必要」那一侧:只登记、不进 loop。
func TestNoticeReviewDroppedStaysSilent(t *testing.T) {
t.Parallel()
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) { return nil, nil }
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, hist := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 1 || recs[0].Content != "群/客户端系统事件" {
t.Errorf("被剔噪后未登记:%+v", recs)
}
}
// TestNoticeReviewFailureFailsSafeSilent 是 fail-safe 的核心用例:
// 审查模型报错 / 超时(这里用 DeadlineExceeded 模拟超时)时**保守侧 = 静默**。
// 变异验证(把 noticeFailSafeProceeds 改成 true)会让本用例变红。
func TestNoticeReviewFailureFailsSafeSilent(t *testing.T) {
t.Parallel()
var hadDeadline bool
review := func(ctx context.Context, _ []adapter.UnifiedMessage) ([]int, error) {
_, hadDeadline = ctx.Deadline()
return nil, context.DeadlineExceeded
}
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if !hadDeadline {
t.Error("审查调用没有超时上限(design/12 §6 实现要点的超时条款)")
}
if chat.SessionID() != "" {
t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.SessionID())
}
}
// TestNoticeReviewerMissingFailsSafeSilent 覆盖「未装配审查模型」:
// 与审查失败同侧(静默),绝不放行未审查内容。
func TestNoticeReviewerMissingFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("未装配审查模型时未静默:%q", chat.SessionID())
}
}
// TestNoticeUnparsableFailsSafeSilent 覆盖「格式异常 / 未知 / 解析失败」:
// 归一化 seam 说不认识 ⇒ 只静默,且绝不把原始载荷喂进上下文。
func TestNoticeUnparsableFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{ok: false}
b, chat, _, hist := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.SessionID())
}
for _, m := range chat.Messages() {
if strings.Contains(m.Content[0].Text, "notice_type") {
t.Errorf("原始载荷进了上下文:%v", m.Content)
}
}
if len(hist.records()) != 0 {
t.Errorf("无安全文本时不该落一条空登记:%+v", hist.records())
}
}
// TestNoticeNormalizerPanicFailsSafeSilent 覆盖畸形载荷把适配器解析器打崩:
// panic 必须折成 fail-safe 静默,绝不牵连常驻后端。
func TestNoticeNormalizerPanicFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{ok: true, panics: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.SessionID())
}
}
// TestNoticeWithoutNormalizerFailsSafeSilent 覆盖适配器未实现归一化 seam 的情况:
// 看不懂平台事件 ⇒ 静默(宁可登记,也不放行)。
func TestNoticeWithoutNormalizerFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &plainAdapter{fakeAdapter{name: "qq-obv11"}}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("无归一化器时未静默:%q", chat.SessionID())
}
}
// TestPreEntryReviewFailSafeIsSilent 直接钉住审查函数本身的 fail-safe 方向
// (未识别 / 空安全文本都不放行)。变异验证会让它变红。
func TestPreEntryReviewFailSafeIsSilent(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ad adapter.PlatformAdapter
msg adapter.UnifiedMessage
}{
{"未识别", &noticeAdapter{ok: false}, groupNoticeMessage()},
{"无归一化器", &plainAdapter{fakeAdapter{name: "qq-obv11"}}, groupNoticeMessage()},
{"安全文本为空", &noticeAdapter{ok: true, notice: adapter.SystemNotice{Kind: adapter.NoticeOther}}, groupNoticeMessage()},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, _, _, _ := noticeFixture(t, tc.ad, nil)
out := b.preEntryReview(context.Background(), &tc.msg, tc.ad)
if !out.Applicable {
t.Fatal("系统事件未被受理")
}
if out.Proceed {
t.Fatalf("fail-safe 方向被反转(应为静默):reason=%q", out.Reason)
}
})
}
}
// TestChatMessageIsNotAReviewSubject 是反向钉:普通聊天消息绝不进传入前审查
// (成本受控:只有系统事件走这条轻审查)。
func TestChatMessageIsNotAReviewSubject(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeJoin, SafeText: "x", Ignorable: true}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
msg := groupNoticeMessage()
msg.ContentType = adapter.ContentText
msg.Content = "大家好"
msg.Sender.Role = adapter.RoleMember
msg.SenderID = "42"
b.processFrom(context.Background(), msg, ad)
if ad.calls != 0 {
t.Errorf("普通聊天消息被送进了归一化 seam:calls=%d", ad.calls)
}
for _, line := range systemLines(chat.Messages()) {
if strings.HasPrefix(line, noticeSystemPrefix) {
t.Errorf("普通聊天消息被当成系统通知注入:%q", line)
}
}
}
// TestNoticeReviewTimeoutIsBounded 钉住超时常量本身,避免被改成无上限。
func TestNoticeReviewTimeoutIsBounded(t *testing.T) {
t.Parallel()
if noticeReviewTimeout <= 0 || noticeReviewTimeout > 30*time.Second {
t.Fatalf("noticeReviewTimeout = %v,应在 (0, 30s] 内", noticeReviewTimeout)
}
}
// TestNoticeFailSafeDirectionConstant 把保守方向钉在测试里(design/12 §6 策略一补充:
// 审查钩子失败一侧 = 静默)。变异验证改的就是这个常量。
func TestNoticeFailSafeDirectionConstant(t *testing.T) {
t.Parallel()
if noticeFailSafeProceeds {
t.Fatal("fail-safe 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)")
}
}