Files
Cyrene_v2/internal/services/channelbridge/events_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

359 lines
14 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
// 本文件是**渠道事件留存接线**的验收(design/26 §4/§5;切片 E3):唯一插入点
// `recordEvent` 的三条要件各有用例 ——
//
// 1. **T1 只计数、不进上下文**(§4.3/§3.1);
// 2. **unknown 不进上下文但逐条落行**(§3.7「落库 + 计数 + 绝不进上下文」);
// 3. **未接线 / 落库失败时入站照常**(§4.3 的 fail-visible 降级,绝不中断入站)。
//
// 另加一道红线钉:普通聊天消息在留存面**零副作用**(主消息路径不受影响)。
// 两轴映射(类别 → 留存档)与「摘要必须净化、绝不夹带平台原文」另在 events_mapping_test.go。
//
// 行/计数的判据本身归 E1(`internal/runtime/events` 的 persistsAsRow 与计数缓冲),
// 这里刻意用**真的 SQLite 存储**(内存库 + 迁移 0021)做端到端断言,而不是假存储:
// 否则「T1 不落行」「unknown 落行」会退化成「我们调了 Record」这种同义反复。
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"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/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// newTestEventStore 打开一个带迁移 0021 的内存库并构造事件存储(E1 的 SQLStore)。
// GC 关闭(保留期与间隔都为 0):本文件验的是写入面,清扫归 E1 自己的用例。
func newTestEventStore(t *testing.T) *events.SQLStore {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, nil); err != nil {
t.Fatalf("内存库跑迁移: %v", err)
}
store, err := events.NewStore(events.Config{DB: conn, GCInterval: 0, Retention: 0})
if err != nil {
t.Fatalf("events.NewStore: %v", err)
}
return store
}
// eventBridgeFixture 组装一条「系统事件入站」所需的桥:受限群会话 + 捕获历史 +
// 记录调用的循环 + 给定的留存面(nil = 未接线)+ 捕获到 buffer 的 DEBUG 日志
// (用它断言日志带 source、且降级只记一条 DEBUG)。
func eventBridgeFixture(t *testing.T, ad adapter.PlatformAdapter, store events.Store) (*Bridge, *stubChatter, *captureHistory, *bytes.Buffer) {
t.Helper()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}}
hist := &captureHistory{}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, err := New(Options{
Log: log, Loop: chat, Sessions: sess, History: hist,
Owner: testOwnerProfile("qq-obv11", "1000"), EventStore: store,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, chat, hist, &buf
}
// eventAdapter 是「按给定归一化结果回答」的最小适配器:它复用 notice_test.go 的
// noticeAdapter,只多一层固定适配器名(多账号命名口径:`platform:confID`)。
func eventAdapter(name string, notice adapter.SystemNotice) *noticeAdapter {
return &noticeAdapter{fakeAdapter: fakeAdapter{name: name}, notice: notice, ok: true}
}
// failingEventStore 是**一直接着失败**的留存面(库不可用 / 表缺失):用于钉住
// 「落库失败只降级,绝不中断入站」(§4.3)。计数不加锁:本文件的调用都在测试
// goroutine 上同步发生。
type failingEventStore struct {
records int
flushes int
}
func (f *failingEventStore) Record(context.Context, events.Event) error {
f.records++
return errors.New("storage unavailable")
}
func (f *failingEventStore) Count(context.Context, events.Filter) (int64, error) { return 0, nil }
func (f *failingEventStore) List(context.Context, events.Filter) ([]events.Event, error) {
return nil, nil
}
func (f *failingEventStore) Counters(context.Context, string) ([]events.Counter, error) {
return nil, nil
}
func (f *failingEventStore) Flush(context.Context) error {
f.flushes++
return nil
}
func (f *failingEventStore) Sweep(context.Context) (events.SweepStats, error) {
return events.SweepStats{}, nil
}
// TestRecordEvent_T1只计数不进上下文_正常:心跳是典型的 T1 遥测 —— 它必须**进计数**
// (「上游到底推没推」的唯一答案),但**绝不**逐条落行(每 ~15s 一帧会把库写胖,§4.3),
// 也绝不进她的上下文。用例让心跳带着 `SelfRelated=true`(真实心跳帧帧都带 self_id):
// 它同时把「帧级遥测不因 self 命中升档」这条取舍钉在这里。
func TestRecordEvent_T1只计数不进上下文_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
Kind: adapter.NoticeOther,
Category: adapter.NoticeCategoryHeartbeat,
PlatformType: "heartbeat",
Ignorable: true,
SelfRelated: true,
})
b, chat, hist, _ := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("心跳驱动了 loop:session=%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("心跳被写进了登记面(会话历史):%+v", recs)
}
rows, err := store.Count(ctx, events.Filter{})
if err != nil {
t.Fatalf("Count: %v", err)
}
if rows != 0 {
t.Fatalf("T1 遥测逐条落行 %d 行,want 0(§4.3:心跳逐帧写库是纯浪费)", rows)
}
if err := store.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
counters, err := store.Counters(ctx, "qq-obv11")
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(counters) != 1 {
t.Fatalf("计数行数 = %d, want 1(T1 必须进计数)", len(counters))
}
got := counters[0]
if got.Count != 1 || got.Tier != events.TierT1 || got.Category != events.Category(adapter.NoticeCategoryHeartbeat) {
t.Fatalf("计数 = %+v, want count=1 tier=t1 category=heartbeat", got)
}
if got.AccountID != "conf0" || got.PlatformType != "heartbeat" {
t.Fatalf("计数键 = (account=%q, type=%q), want (conf0, heartbeat)", got.AccountID, got.PlatformType)
}
}
// TestRecordEvent_unknown不进上下文但落行_正常:未知类型是「兜底不等于放行」的正面用例
// (§3.7)—— 它照旧逐条落行(可查)并进计数,但**绝不**进她的上下文,且平台原生类型码
// 必须原样保留(那是「上游到底报了什么类型」的唯一凭据)。
func TestRecordEvent_unknown不进上下文但落行_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
Kind: adapter.NoticeUnknown,
Category: adapter.NoticeCategoryUnknown,
PlatformType: "mystery_frame",
Ignorable: true,
})
b, chat, hist, _ := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("unknown 事件进了真会话:session=%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("unknown 事件被登记进会话历史:%+v", recs)
}
rows, err := store.List(ctx, events.Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("unknown 事件落行数 = %d, want 1(unknown 必须逐条留痕)", len(rows))
}
ev := rows[0]
if ev.Tier != events.TierT1 || ev.Category != events.CategoryUnknown {
t.Fatalf("落行档位/类别 = (%s,%s), want (t1,unknown)", ev.Tier, ev.Category)
}
if ev.PlatformType != "mystery_frame" {
t.Fatalf("平台原生类型码 = %q, want mystery_frame(原样保留是排障凭据)", ev.PlatformType)
}
if ev.Platform != "qq-obv11" || ev.ChannelID != "g1" || ev.ChannelType != string(adapter.ChannelGroup) {
t.Fatalf("行来源字段 = (platform=%q, channel=%q, type=%q)", ev.Platform, ev.ChannelID, ev.ChannelType)
}
if ev.AccountID != "conf0" {
t.Fatalf("account_id = %q, want conf0(多账号覆盖;空串留给单账号/未知)", ev.AccountID)
}
if !strings.Contains(ev.Summary, "mystery_frame") {
t.Fatalf("摘要 = %q, want 含未识别的平台原生类型码", ev.Summary)
}
}
// TestRecordEvent_自身回声升T2落行带出站id_正常 钉住切 M13 修掉的缺口(原 M12 登记):
// 她自己发出消息的回声(message_sent)**不是帧级遥测** —— self 命中必须把它从 T1 升到 T2
// 逐条落行,`message_id`(出站消息 id)随之留在库里;此前它被帧级豁免挡回 T1,id 被丢。
func TestRecordEvent_自身回声升T2落行带出站id_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
Kind: adapter.NoticeOther,
Category: adapter.NoticeCategoryMessageSent,
PlatformType: "message_sent",
Ignorable: true,
SelfRelated: true,
RefMessage: "out-msg-42",
})
b, chat, hist, _ := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.SessionID() != "" {
t.Fatalf("出站回声驱动了 loop:session=%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("出站回声被登记进会话历史:%+v", recs)
}
rows, err := store.List(ctx, events.Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("出站回声落行数 = %d, want 1(self 命中 ⇒ T2 逐条落行)", len(rows))
}
ev := rows[0]
if ev.Tier != events.TierT2 || ev.Category != events.Category(adapter.NoticeCategoryMessageSent) {
t.Fatalf("落行档位/类别 = (%s,%s), want (t2,message_sent)", ev.Tier, ev.Category)
}
if ev.MessageID != "out-msg-42" {
t.Fatalf("出站消息 id = %q, want out-msg-42(id 必须留在库里)", ev.MessageID)
}
}
// TestRecordEvent_未接线时入站照常_异常:`Options.EventStore == nil` 时降级为
// 「内存计数 + 首次一条 DEBUG」,**绝不**中断入站 —— 高价值事件的升级真会话行为必须
// 逐字不变(fail-visible 而非 fail-crash,§4.3)。
func TestRecordEvent_未接线时入站照常_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
ad := eventAdapter("qq-obv11", adapter.SystemNotice{
Kind: adapter.NoticeLeave,
Category: adapter.NoticeCategoryLeave,
PlatformType: "group_decrease",
SafeText: "系统通知:她(机器人账号)被移出了群聊",
HighValue: true,
SelfRelated: true,
})
b, chat, _, buf := eventBridgeFixture(t, ad, nil)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.SessionID() != "sess-g1" {
t.Fatalf("未接线改变了入站行为:高价值事件没进真会话(session=%q)", chat.SessionID())
}
stats := b.EventFallback()
if stats.Frames != 1 {
t.Fatalf("降级计数 = %d, want 1(未接线的帧也必须留下可查的计数)", stats.Frames)
}
if stats.Keys["qq-obv11/group_decrease"] != 1 {
t.Fatalf("降级计数键 = %v, want qq-obv11/group_decrease=1", stats.Keys)
}
logged := buf.String()
if !strings.Contains(logged, "source=services/channelbridge/events") {
t.Fatalf("降级日志没有来源标记(AGENTS §10 禁无来源裸日志):%q", logged)
}
if !strings.Contains(logged, "channel event not recorded") {
t.Fatalf("未接线时没有留下可见痕迹:%q", logged)
}
// 「DEBUG 一次」:第二帧不再刷日志,但计数继续累加(有界、不刷屏)。
buf.Reset()
b.processFrom(ctx, groupNoticeMessage(), ad)
if again := b.EventFallback().Frames; again != 2 {
t.Fatalf("降级计数 = %d, want 2", again)
}
if got := buf.String(); strings.Contains(got, "channel event not recorded") {
t.Fatalf("降级日志被逐帧刷写(应只在首帧记一条 DEBUG):%q", got)
}
}
// TestRecordEvent_落库失败仍不中断入站_异常:存储报错 ⇒ WARN + 内存计数 + 继续 ——
// **不关留存面、不中断回合、不让入站失败**(§4.3)。
func TestRecordEvent_落库失败仍不中断入站_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := &failingEventStore{}
ad := eventAdapter("qq-obv11", adapter.SystemNotice{
Kind: adapter.NoticeLeave,
Category: adapter.NoticeCategoryLeave,
PlatformType: "group_decrease",
SafeText: "系统通知:她(机器人账号)被移出了群聊",
HighValue: true,
})
b, chat, _, buf := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if store.records != 1 {
t.Fatalf("留存面被调用 %d 次, want 1", store.records)
}
if chat.SessionID() != "sess-g1" {
t.Fatalf("落库失败中断了入站(高价值事件没进真会话):session=%q", chat.SessionID())
}
if stats := b.EventFallback(); stats.Frames != 1 {
t.Fatalf("落库失败的帧没有进降级计数:%+v", stats)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") ||
!strings.Contains(logged, "channel event not persisted") ||
!strings.Contains(logged, "source=services/channelbridge/events") {
t.Fatalf("落库失败的可观测面不足(要 WARN + source):%q", logged)
}
}
// TestRecordEvent_聊天消息零副作用_正常:普通聊天消息不是渠道事件 —— 留存面**一次都
// 不该被调用**(主消息路径不受影响,§5.1)。
func TestRecordEvent_聊天消息零副作用_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11", adapter.SystemNotice{Kind: adapter.NoticeJoin})
b, _, _, _ := eventBridgeFixture(t, ad, store)
msg := groupNoticeMessage()
msg.ContentType, msg.Content = adapter.ContentText, "大家好"
msg.Sender.Role, msg.SenderID = adapter.RoleMember, "42"
b.processFrom(ctx, msg, ad)
rows, err := store.Count(ctx, events.Filter{})
if err != nil {
t.Fatalf("Count: %v", err)
}
if rows != 0 || store.PendingKeys() != 0 {
t.Fatalf("普通聊天消息进了留存面:rows=%d pending=%d", rows, store.PendingKeys())
}
if stats := b.EventFallback(); stats.Frames != 0 {
t.Fatalf("普通聊天消息进了降级计数:%+v", stats)
}
}