Files
Cyrene_v2/internal/services/backthink/selfwake_status_test.go
T
AskaEth 4213b268fe feat(backthink): 自排的成本可见面(C2 读侧,Y3)
- GET /api/v1/admin/backthink 加可选 ?trigger=(闭集=内建触发标签 + 本服务订阅的事件主题;
  未知/空 ⇒ 400 invalid_trigger,不静默忽略;合法值下推到 SQL ⇒ limit 是匹配条数)
- 新端点 GET /api/v1/admin/backthink/self-wake(writeAuth):闸的只读镜像
  {available,running,reason?,configFile?,gate{pending,used,limit,windowSec,default*,*IsDefault}}
  三态分开(未接线 / 有配置未运行 / 运行中),读它**不消耗额度**
- 零新 env 键;旋钮源 = $DATA_DIR/config/backthink.json(**写面未做**,前置=组合根接线,已登记)
- 键名沿用设计与现有代码的 selfWakePerWindow(派工书写错成 selfWakeLimit,未改键名以免废掉主人已写的配置)
- 5 个变异(未知值静默忽略/关掉下推/恒报运行中/白名单摘键/前端把看不到画成 0/0)
⚠️ 本片改了 4 个文件面外文件(admin_query.go/routes.go/api.ts/adminSystem.ts)——已逐条说明理由
2026-10-06 21:48:49 +08:00

163 lines
6.4 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 backthink
import (
"context"
"testing"
"time"
)
// 本文件守切片 C2 的**用量可见面**(design/12 §N C.5 的读侧):自排闸的只读快照
// 必须与工具收据(SelfWakeReceipt)**同一口径**(同一个时间源、同一个窗口、
// 同一个「消耗之后取值」),并且**读它不改变闸的状态**。
// TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot 是主判据:
// 排一轮后 `Used` 必须等于收据里的 Used,待跑槽随之变占用;额度用尽后
// `Used` 停在 `Limit`(不会因为一次拒绝而虚增)。
//
// 变异:把 SelfWakeStatus 的 Used 硬编码成 0(或改成 `len(granted)` 而不丢弃
// 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。
func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
t.Parallel()
// worker 卡住 ⇒ 排进去的那一轮不会把槽取走,`pending` 因此可确定地断言。
w := newWakeThinker()
w.holdSelfWake = make(chan struct{})
b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w)
// 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。
before := mustStatus(t, b)
if !before.Running {
t.Fatal("已 Start 的服务其快照必须报告 Running=true")
}
if before.Used != 0 || before.Pending {
t.Fatalf("刚启动的快照 = %+v, want Used=0 / Pending=false", before)
}
if before.Limit != 2 || before.WindowSec != 3600 {
t.Fatalf("快照口径 = %+v, want Limit=2 / WindowSec=3600", before)
}
if before.LimitIsDefault {
t.Fatalf("上限 2 不该被算成默认值:%+v", before)
}
// 窗口仍取了代码默认(1 小时)⇒ WindowIsDefault 就是 true(这是「生效值恰好等于
// 默认值」如实上报,而不是「配置文件没写」的断言)。
if !before.WindowIsDefault {
t.Fatalf("窗口取了默认值却没标记:%+v", before)
}
if before.DefaultLimit != defaultSelfWakeLimit || before.DefaultWindowSec != int(defaultSelfWakeWindow/time.Second) {
t.Fatalf("默认值口径 = %+v, want %d / %d", before,
defaultSelfWakeLimit, int(defaultSelfWakeWindow/time.Second))
}
receipt, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "排一轮"})
if err != nil {
t.Fatalf("WakeNow: %v", err)
}
if !receipt.Accepted {
t.Fatalf("收据 = %+v, want Accepted", receipt)
}
after := mustStatus(t, b)
if after.Used != receipt.Used || after.Used != 1 {
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 1(同一口径)", after.Used, receipt.Used)
}
if !after.Pending {
t.Fatalf("排入后待跑槽应报告占用:%+v", after)
}
// 第二次撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
if _, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "再排一轮"}); err != nil {
t.Fatalf("WakeNow 合并: %v", err)
}
merged := mustStatus(t, b)
if merged.Used != 1 {
t.Fatalf("合并后 Used = %d, want 1(合并不额外占额度)", merged.Used)
}
if merged.Limit != 2 {
t.Fatalf("合并后 Limit = %d, want 2", merged.Limit)
}
}
// TestSelfWakeStatus_StoppedServiceIsNotRunning 钉住「未运行」这一态:
// 服务在跑之前(以及 Stop 之后)快照必须如实报 Running=false,好让面板显示
// 「此刻排不出新的一轮」而不是「已用 0 次」。
//
// 变异:把 SelfWakeStatus 的 Running 直接写 true ⇒ 本用例变红。
func TestSelfWakeStatus_StoppedServiceIsNotRunning(t *testing.T) {
t.Parallel()
b, _ := newBackthink(t, Config{}, Deps{Thinker: newWakeThinker(), State: &mockState{}})
st := mustStatus(t, b)
if st.Running {
t.Fatalf("未 Start 的快照 = %+v, want Running=false", st)
}
// 未运行时额度必然是 0,且上限已 normalize(面板仍能看到「上限 4/小时」)。
if st.Used != 0 {
t.Fatalf("未运行时 Used = %d, want 0", st.Used)
}
if st.Limit != defaultSelfWakeLimit || !st.LimitIsDefault || !st.WindowIsDefault {
t.Fatalf("零配置的快照 = %+v, want 默认口径 %d/%v", st,
defaultSelfWakeLimit, defaultSelfWakeWindow)
}
b.Start(context.Background())
if !mustStatus(t, b).Running {
t.Fatal("Start 之后快照必须报告 Running=true")
}
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
if mustStatus(t, b).Running {
t.Fatal("Stop 之后快照必须报告 Running=false")
}
}
// TestSelfWakeStatus_ReadDoesNotConsumeBudget 钉住「读是纯读」:反复读快照
// 不能让额度减少,也不能让待跑槽变化。
//
// 变异:把快照实现改成调用 limiter.Allow()(成功即消耗)⇒ 本用例在 Used 处变红。
func TestSelfWakeStatus_ReadDoesNotConsumeBudget(t *testing.T) {
t.Parallel()
b, _ := newBackthink(t, Config{SelfWakeLimit: 1, SelfWakeWindow: time.Hour},
Deps{Thinker: newWakeThinker(), State: &mockState{}})
b.Start(context.Background())
t.Cleanup(func() { _ = b.Stop(context.Background()) })
for i := 0; i < 5; i++ {
if got := mustStatus(t, b).Used; got != 0 {
t.Fatalf("第 %d 次读快照后 Used = %d, want 0(读不改闸)", i+1, got)
}
}
// 读完之后额度仍然可用:唯一那一格必须能真排上一轮。
receipt, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "读完之后再排"})
if err != nil {
t.Fatalf("WakeNow: %v", err)
}
if !receipt.Accepted || receipt.Used != 1 {
t.Fatalf("收据 = %+v, want Accepted 且 Used=1(读快照没有偷走额度)", receipt)
}
}
// TestSelfWakeStatus_UnreadableOnlyWhenGateMissing 钉住第二个返回值的诚实语义:
// 正常构造的服务一定有闸(New 里建),只有零值 Backthink 才报告「看不到」。
//
// 变异:把 `return SelfWakeStatus{}, false` 改成 `true` ⇒ 本用例变红
// (调用方会把 0/0 当成真实用量)。
func TestSelfWakeStatus_UnreadableOnlyWhenGateMissing(t *testing.T) {
t.Parallel()
if _, ok := (&Backthink{}).SelfWakeStatus(); ok {
t.Fatal("零值 Backthink 没有闸,必须报告 ok=false(不得伪造 0/0)")
}
b, _ := newBackthink(t, Config{}, Deps{Thinker: newWakeThinker(), State: &mockState{}})
if _, ok := b.SelfWakeStatus(); !ok {
t.Fatal("经 New 构造的服务一定有闸,必须报告 ok=true")
}
}
// mustStatus 取快照并在「闸不在」时直接失败(正常构造的服务不该走到那一步)。
func mustStatus(t *testing.T, b *Backthink) SelfWakeStatus {
t.Helper()
st, ok := b.SelfWakeStatus()
if !ok {
t.Fatal("SelfWakeStatus 报告闸不在:正常构造的服务不该这样")
}
return st
}