Files
Cyrene_v2/internal/runtime/events/account_status_test.go
T
AskaEth 99a644d545 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」**
(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
2026-10-06 09:03:53 +08:00

396 lines
15 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 events
// 本文件钉住**账号在线状态迁移**的判定、归一化与落行(design/26 §2.6;切片 E7)。
//
// 断言的是**库里的事实**(多了一行没有、那一行的 tier/category/platform_type/summary
// 是不是预期的),不按日志文本断言 —— 与 store_test.go 同一口径。
//
// 覆盖的三类口径,每一类都对应一条设计约束:
// - 九种 (前态 → 后态) 组合**逐个落行**,包括涉及「未知」的那四种(§2.6 的
// `known` 三态;§1.1「绝不静默」);
// - **没变就不写**(§4.3:账号状态不是逐帧写库的高频遥测,只有迁移是事实);
// - **缺平台是调用方的编程错误**(ErrNoPlatform),而**缺通道码只是归一化**
// (不报错、不丢类)。
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// accountStatusStoreStub 是只记「落了什么」的测试替身:它让「判定」与「落库」两面
// 可以分别断言(真库的端到端断言在下面用 newTestStore)。
type accountStatusStoreStub struct {
mu sync.Mutex
rows []Event
err error
calls int
}
func (s *accountStatusStoreStub) Record(_ context.Context, ev Event) error {
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
if s.err != nil {
return s.err
}
s.rows = append(s.rows, ev)
return nil
}
func (s *accountStatusStoreStub) Count(context.Context, Filter) (int64, error) { return 0, nil }
func (s *accountStatusStoreStub) List(context.Context, Filter) ([]Event, error) {
return nil, nil
}
func (s *accountStatusStoreStub) Counters(context.Context, string) ([]Counter, error) {
return nil, nil
}
func (s *accountStatusStoreStub) Flush(context.Context) error { return nil }
func (s *accountStatusStoreStub) Sweep(context.Context) (SweepStats, error) {
return SweepStats{}, nil
}
// recorded 返回已记录的行的副本(避免调用方在锁外读被并发改写)。
func (s *accountStatusStoreStub) recorded() []Event {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Event(nil), s.rows...)
}
// sample 构造一条观测样本(平台固定 qq-obv11,账号 id 由用例决定)。
func sample(account string, online, known bool) StatusSample {
return StatusSample{
Platform: PlatformQQ,
AccountID: account,
Online: online,
Known: known,
Source: StatusSourceProbe,
}
}
// TestStatusTracker_九种迁移逐个落行:把 (前态 → 后态) 的**全部**组合跑一遍。
//
// 为什么逐个列而不是抽样:§2.6 的价值全在「她自己的账号状态**变了**」这一件事上,
// 而实现里最容易漏的恰恰是「未知 ↔ 确知」那四条边 —— 一个 `if !known { continue }`
// 就会把它们整类丢掉(这正是 gateway 的 liveAccountOnline 必须缺席、而留存面
// 必须留痕的那条分界线)。
func TestStatusTracker_九种迁移逐个落行(t *testing.T) {
t.Parallel()
const acct = "bot-1"
type step struct {
online bool
known bool
}
for _, tc := range []struct {
name string
seq []step
prev AccountState
next AccountState
summary string
}{
{"首次→在线", []step{{true, true}}, "", AccountStateOnline, "账号在线(首次确知)"},
{"首次→离线", []step{{false, true}}, "", AccountStateOffline, "账号离线(首次确知)"},
{"首次→未知", []step{{false, false}}, "", AccountStateUnknown, "账号状态未知(尚未探到)"},
{"在线→离线", []step{{true, true}, {false, true}}, AccountStateOnline, AccountStateOffline, "账号离线(在线→离线)"},
{"离线→在线", []step{{false, true}, {true, true}}, AccountStateOffline, AccountStateOnline, "账号上线(离线→在线)"},
{"未知→在线", []step{{false, false}, {true, true}}, AccountStateUnknown, AccountStateOnline, "账号上线(未知→在线)"},
{"未知→离线", []step{{false, false}, {false, true}}, AccountStateUnknown, AccountStateOffline, "账号离线(未知→离线)"},
{"在线→未知", []step{{true, true}, {false, false}}, AccountStateOnline, AccountStateUnknown, "账号状态未知(在线→未知)"},
{"离线→未知", []step{{false, true}, {false, false}}, AccountStateOffline, AccountStateUnknown, "账号状态未知(离线→未知)"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, testNow)
ctx := context.Background()
// 前几步只负责把状态推到 tc.prev(它们自己也是迁移,照常落行)。
for i := 0; i < len(tc.seq)-1; i++ {
if _, err := tr.Observe(ctx, sample(acct, tc.seq[i].online, tc.seq[i].known)); err != nil {
t.Fatalf("铺垫第 %d 步: %v", i, err)
}
}
last := tc.seq[len(tc.seq)-1]
res, err := tr.Observe(ctx, sample(acct, last.online, last.known))
if err != nil {
t.Fatalf("Observe: %v", err)
}
if !res.Changed || res.State != tc.next || res.Previous != tc.prev {
t.Fatalf("res = {changed:%v state:%s prev:%s}, want {true %s %s}",
res.Changed, res.State, res.Previous, tc.next, tc.prev)
}
if !res.Recorded {
t.Fatalf("最后一步未落库(Recorded=false)")
}
if res.Event.Summary != tc.summary {
t.Errorf("summary = %q, want %q", res.Event.Summary, tc.summary)
}
// 落行进库的字段逐列核对:T2(§2.6 高价值)+ lifecycle(§3.1 的派生关系)
// + 探活通道码 + 合成 id system 的角色(无当事人的事件)。
ev := res.Event
if ev.Tier != TierT2 || ev.Category != CategoryLifecycle {
t.Errorf("tier/category = %s/%s, want t2/lifecycle", ev.Tier, ev.Category)
}
if ev.PlatformType != StatusSourceProbe {
t.Errorf("platform_type = %q, want %q", ev.PlatformType, StatusSourceProbe)
}
if !ev.SelfRelated {
t.Error("self_related = false, want true(这是她自己的账号)")
}
if ev.ActorID != "" || ev.ActorRole != RoleSystem {
t.Errorf("actor = %q/%q, want \"\"/system(状态由平台侧上报,没有人的当事人)",
ev.ActorID, ev.ActorRole)
}
if ev.ChannelID != "" || ev.MessageID != "" {
t.Errorf("channel/message = %q/%q, want 空(账号状态不属任何会话或消息)",
ev.ChannelID, ev.MessageID)
}
// 读回库:最后一步的那一行必须在,且 summary 逐字一致。
rows, err := store.List(ctx, Filter{Platform: PlatformQQ})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != len(tc.seq) {
t.Fatalf("库里有 %d 行, want %d(每一步迁移一行)", len(rows), len(tc.seq))
}
if rows[0].Summary != tc.summary {
t.Errorf("库里最新一行 summary = %q, want %q", rows[0].Summary, tc.summary)
}
if rows[0].Tier != TierT2 || !rows[0].SelfRelated {
t.Errorf("读回的行 = %+v, want t2 + self_related", rows[0])
}
})
}
}
// TestStatusTracker_未变化不落行:同一状态连采多次只留**首行**。
// 这是 §4.3 与 §2.6 的分界线:逐帧写库是纯浪费,而迁移是事实。
func TestStatusTracker_未变化不落行(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, testNow)
ctx := context.Background()
for i := 0; i < 5; i++ {
res, err := tr.Observe(ctx, sample("bot-1", true, true))
if err != nil {
t.Fatalf("第 %d 次 Observe: %v", i, err)
}
if want := i == 0; res.Changed != want {
t.Fatalf("第 %d 次 Changed = %v, want %v", i, res.Changed, want)
}
}
rows, err := store.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("库里有 %d 行, want 1(只有首次观测是迁移)", len(rows))
}
}
// TestStatusTracker_缺平台是编程错误:样本没有平台 ⇒ 包装的 ErrNoPlatform,
// 且**什么都不写**(既落不进行,也不在迁移表里留一个空键的假状态)。
func TestStatusTracker_缺平台是编程错误(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, testNow)
res, err := tr.Observe(context.Background(), StatusSample{Online: true, Known: true})
if !errors.Is(err, ErrNoPlatform) {
t.Fatalf("err = %v, want wrap of ErrNoPlatform", err)
}
if res.Changed || res.Recorded {
t.Errorf("res = %+v, want 未判定也未落库", res)
}
if rows, _ := store.List(context.Background(), Filter{}); len(rows) != 0 {
t.Fatalf("库里 %d 行, want 0", len(rows))
}
if states := tr.States(); len(states) != 0 {
t.Errorf("States() = %v, want 空(坏样本不该污染迁移表)", states)
}
}
// TestStatusTracker_通道码缺失只是归一化:空通道码不报错、不丢类,
// 归一成固定兜底码写进 platform_type(§4.1:排障要能分辨「这条是谁记的」)。
func TestStatusTracker_通道码缺失只是归一化(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, testNow)
res, err := tr.Observe(context.Background(), StatusSample{
Platform: PlatformQQ, AccountID: "bot-1", Online: true, Known: true,
})
if err != nil {
t.Fatalf("Observe: %v", err)
}
if !res.Recorded {
t.Fatal("Recorded = false, want true(缺通道码不该阻止落行)")
}
if res.Event.PlatformType != statusSourceFallback {
t.Errorf("platform_type = %q, want %q", res.Event.PlatformType, statusSourceFallback)
}
}
// TestStatusTracker_未接线只判定不落库:store 为 nil 时照常判迁移(调用方仍能据此
// 知道「状态变了」),但 Recorded=false —— 绝不假装写过(§4.3 的 fail-visible)。
func TestStatusTracker_未接线只判定不落库(t *testing.T) {
t.Parallel()
tr := NewStatusTracker(nil, testNow)
res, err := tr.Observe(context.Background(), sample("bot-1", false, true))
if err != nil {
t.Fatalf("Observe: %v", err)
}
if !res.Changed || res.State != AccountStateOffline {
t.Errorf("res = %+v, want changed + offline", res)
}
if res.Recorded {
t.Error("Recorded = true, want false(未接线就没有可查的行)")
}
}
// TestStatusTracker_落库失败_错误上抛且不声称:§4.3 要求逐条留存的失败**必须**让
// 调用方看见(E3 据此记 WARN)。此时 Recorded 必须为 false —— 否则面板会收到一条
// 查询面永远查不到的幽灵帧。
func TestStatusTracker_落库失败_错误上抛且不声称(t *testing.T) {
t.Parallel()
boom := errors.New("db 挂了")
stub := &accountStatusStoreStub{err: boom}
tr := NewStatusTracker(stub, testNow)
res, err := tr.Observe(context.Background(), sample("bot-1", true, true))
if err == nil || !errors.Is(err, boom) {
t.Fatalf("err = %v, want wrap of %v", err, boom)
}
if res.Recorded {
t.Error("Recorded = true, want false(落库失败了)")
}
if !res.Changed {
t.Error("Changed = false, want true(判定与落库是两件事)")
}
if n := len(stub.recorded()); n != 0 {
t.Errorf("替身收到 %d 行, want 0", n)
}
}
// TestStatusTracker_并发观测同一迁移只落一行:8 路并发观测同一个账号的同一次迁移。
// 判定表在临界区内读写,因此只能有一个赢家 —— 多落几行就等于「一次掉线写了 N 行」。
// (刻意只起 8 个 goroutine:AGENTS §10 的压测纪律要求有界,不堆进程。)
func TestStatusTracker_并发观测同一迁移只落一行(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, testNow)
const n = 8
var wg sync.WaitGroup
changed := make([]bool, n)
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
res, err := tr.Observe(context.Background(), sample("bot-1", true, true))
if err != nil {
t.Errorf("Observe(%d): %v", i, err)
return
}
changed[i] = res.Changed
}(i)
}
wg.Wait()
winners := 0
for _, c := range changed {
if c {
winners++
}
}
if winners != 1 {
t.Errorf("Changed 为真的路数 = %d, want 1", winners)
}
rows, err := store.List(context.Background(), Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("库里有 %d 行, want 1(同一迁移只该落一行)", len(rows))
}
}
// TestStatusTracker_多账号互不影响:两个账号各自迁移,行数与账号对应,
// 绝不因为「同平台」把它们糊成一行。
func TestStatusTracker_多账号互不影响(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, testNow)
ctx := context.Background()
mustObserve(t, tr, sample("bot-1", true, true))
mustObserve(t, tr, sample("bot-2", false, true))
// 第二轮的三个观测都不改变状态 ⇒ 不落行。
mustObserve(t, tr, sample("bot-1", true, true))
mustObserve(t, tr, sample("bot-2", false, true))
rows, err := store.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 2 {
t.Fatalf("库里有 %d 行, want 2", len(rows))
}
ids := map[string]bool{rows[0].AccountID: true, rows[1].AccountID: true}
if !ids["bot-1"] || !ids["bot-2"] {
t.Errorf("account_id = %v, want bot-1 与 bot-2 各一行", ids)
}
if states := tr.States(); states[PlatformQQ+"/bot-1"] != AccountStateOnline ||
states[PlatformQQ+"/bot-2"] != AccountStateOffline {
t.Errorf("States() = %v, want bot-1 在线 / bot-2 离线", states)
}
}
// TestStatusSummaries_九种组合逐条有文案:文案表必须覆盖全部 (前态, 后态) 组合且每一条
// 非空 —— 「漏了一种」在这里变红,而不是悄悄落一行空 summary 进库(空摘要等于没留痕)。
func TestStatusSummaries_九种组合逐条有文案(t *testing.T) {
t.Parallel()
table := statusSummaries()
if n := len(table); n != 9 {
t.Errorf("文案表 %d 条, want 9(3 种首次观测 + 3×2 种迁移)", n)
}
prevStates := []AccountState{"", AccountStateOnline, AccountStateOffline, AccountStateUnknown}
nextStates := []AccountState{AccountStateOnline, AccountStateOffline, AccountStateUnknown}
for _, prev := range prevStates {
for _, next := range nextStates {
if prev == next {
continue // 同态不构成迁移
}
if s := table[statusSummaryKey(prev, next)]; s == "" {
t.Errorf("(%q → %q) 没有固定文案,want 一句话常量", prev, next)
}
}
}
}
// mustObserve 是「这一步必须成功」的简写(失败直接结束用例)。
func mustObserve(t *testing.T, tr *StatusTracker, s StatusSample) StatusResult {
t.Helper()
res, err := tr.Observe(context.Background(), s)
if err != nil {
t.Fatalf("Observe(%s/%s): %v", s.Platform, s.AccountID, err)
}
return res
}
// TestStatusTracker_时钟零值时由存储层补当前时刻:CreatedAt 留零值(任何真实写入方
// 都不该自己造时刻),落库后读回来必须是一个**非零**时刻,而不是 1970(1970 会被
// 保留期立刻扫掉 —— 那正是「刚发生的证据反被丢掉」)。
func TestStatusTracker_时钟零值时由存储层补当前时刻(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
tr := NewStatusTracker(store, func() time.Time { return time.Time{} })
res := mustObserve(t, tr, sample("bot-1", true, true))
if !res.Event.CreatedAt.IsZero() {
t.Fatalf("CreatedAt = %v, want 零值交给存储层填", res.Event.CreatedAt)
}
rows, err := store.List(context.Background(), Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 || rows[0].CreatedAt.IsZero() {
t.Fatalf("读回的行 = %+v, want 一个非零 created_at", rows)
}
}