- 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)——已逐条说明理由
167 lines
6.1 KiB
Go
167 lines
6.1 KiB
Go
package backthink
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件守切片 C2 的两条读侧能力(design/12 §N C.5/C.6):
|
||
// 1. **触发源取值域**(trigger_domain.go 的唯一真源)—— 内建标签 + 订阅的事件主题,
|
||
// 它是 admin `?trigger=` 筛选白名单的依据;
|
||
// 2. **按触发源读取**(ListByTrigger 下推到 SQL)—— `limit` 必须是**匹配**条数,
|
||
// 而不是「先取一页再筛掉不匹配的」(后者会给面板一个静默的假空值)。
|
||
|
||
// TestTriggerFilterValues_CoversBuiltinsAndSubscribedSubjects 钉住白名单的**构成**:
|
||
// 三个内建标签(心跳 / 启动 / 自排)+ 本服务订阅的全部事件主题。
|
||
//
|
||
// 变异:把 SelfWakeSource 从列表里摘掉 ⇒ 本用例变红(面板将筛不到自排轮,
|
||
// 而它恰恰是这一档最想看的);少列一个订阅主题 ⇒ 同红。
|
||
func TestTriggerFilterValues_CoversBuiltinsAndSubscribedSubjects(t *testing.T) {
|
||
t.Parallel()
|
||
values := TriggerFilterValues()
|
||
set := map[string]bool{}
|
||
for _, v := range values {
|
||
if set[v] {
|
||
t.Fatalf("白名单里有重复取值 %q:%v", v, values)
|
||
}
|
||
set[v] = true
|
||
}
|
||
for _, want := range []string{HeartbeatSource, StartupSource, SelfWakeSource} {
|
||
if !set[want] {
|
||
t.Fatalf("白名单缺内建标签 %q:%v", want, values)
|
||
}
|
||
}
|
||
subjects := EventSubjects()
|
||
if len(subjects) == 0 {
|
||
t.Fatal("订阅主题表为空:白名单会漏掉全部事件轮")
|
||
}
|
||
for _, s := range subjects {
|
||
if !set[s] {
|
||
t.Fatalf("白名单缺订阅主题 %q:%v", s, values)
|
||
}
|
||
}
|
||
// 反向:白名单里的每个取值都必须真的可能出现在 Record.Trigger 上
|
||
// (= 内建标签 或 订阅主题)—— 多列一个「不可能出现」的值会让筛选永远返回空。
|
||
for _, v := range values {
|
||
if v == HeartbeatSource || v == StartupSource || v == SelfWakeSource {
|
||
continue
|
||
}
|
||
if !isEventSubject(v) {
|
||
t.Fatalf("白名单含一个不可能是触发源的取值 %q:%v", v, values)
|
||
}
|
||
}
|
||
}
|
||
|
||
// isEventSubject 只在本测试里做反向核对(生产侧没有这个判定:白名单由
|
||
// TriggerFilterValues 拼出来,不需要反查)。
|
||
func isEventSubject(v string) bool {
|
||
for _, s := range EventSubjects() {
|
||
if s == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TestIsTriggerValue_RejectsUnknown 钉住白名单判定:已知取值通行,未知/空串一律拒。
|
||
//
|
||
// 变异:把 IsTriggerValue 改成恒 true ⇒ 服务端筛选会「接受一切但筛不中」,
|
||
// 网关那条 400 的用例随之变红。
|
||
func TestIsTriggerValue_RejectsUnknown(t *testing.T) {
|
||
t.Parallel()
|
||
for _, ok := range []string{HeartbeatSource, StartupSource, SelfWakeSource, "iot.device_update"} {
|
||
if !IsTriggerValue(ok) {
|
||
t.Fatalf("IsTriggerValue(%q) = false, want true", ok)
|
||
}
|
||
}
|
||
for _, bad := range []string{"", "event", "self-wake", "HEARTBEAT", "all", "iot"} {
|
||
if IsTriggerValue(bad) {
|
||
t.Fatalf("IsTriggerValue(%q) = true, want false", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestStore_ListByTrigger_IsServerSideFilter 钉住**下推**语义:筛选后 `limit` 数的是
|
||
// 匹配条数(而不是「本页里恰好匹配的条数」)。
|
||
//
|
||
// 变异:把 ListByTrigger 改回「List 之后内存筛」的退化实现(拿 limit 条再筛)⇒
|
||
// 下面的 `limit=1` 断言会拿到 0 条(最新一条是心跳,被筛掉后没有别的自排)⇒ 变红。
|
||
func TestStore_ListByTrigger_IsServerSideFilter(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s, err := NewStore(ctx, newTestDB(t), discardLog())
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
base := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
// 交替写入:心跳(最新)/ 自排 / 心跳 / 自排 / 心跳(最旧)。
|
||
triggers := []string{HeartbeatSource, SelfWakeSource, HeartbeatSource, SelfWakeSource, HeartbeatSource}
|
||
for i, tr := range triggers {
|
||
if _, err := s.Append(ctx, Record{Trigger: tr, At: base.Add(time.Duration(i) * time.Minute)}); err != nil {
|
||
t.Fatalf("Append %d: %v", i, err)
|
||
}
|
||
}
|
||
|
||
got, err := s.ListByTrigger(ctx, SelfWakeSource, 10)
|
||
if err != nil {
|
||
t.Fatalf("ListByTrigger: %v", err)
|
||
}
|
||
if len(got) != 2 {
|
||
t.Fatalf("自排记录 = %d 条, want 2(%+v)", len(got), got)
|
||
}
|
||
for _, r := range got {
|
||
if r.Trigger != SelfWakeSource {
|
||
t.Fatalf("筛选结果含非自排行:%+v", r)
|
||
}
|
||
}
|
||
// 最新的在前(与 List 同口径)。
|
||
if got[0].ID != 4 || got[1].ID != 2 {
|
||
t.Fatalf("顺序 = %d,%d, want 4,2(最新在前)", got[0].ID, got[1].ID)
|
||
}
|
||
|
||
// 🔴 下推的关键判据:limit 数的是**匹配**条数。
|
||
one, err := s.ListByTrigger(ctx, SelfWakeSource, 1)
|
||
if err != nil {
|
||
t.Fatalf("ListByTrigger limit=1: %v", err)
|
||
}
|
||
if len(one) != 1 || one[0].ID != 4 {
|
||
t.Fatalf("limit=1 结果 = %+v, want 只有 id=4(匹配条数,不是先取一页再筛)", one)
|
||
}
|
||
|
||
// 未出现过的合法取值 ⇒ 空列表(不是错误)。
|
||
none, err := s.ListByTrigger(ctx, "reminder.trigger", 10)
|
||
if err != nil {
|
||
t.Fatalf("ListByTrigger(reminder.trigger): %v", err)
|
||
}
|
||
if len(none) != 0 {
|
||
t.Fatalf("无匹配时应返回空列表,得到 %+v", none)
|
||
}
|
||
}
|
||
|
||
// TestBackthink_ListByTrigger_DelegatesToStore 钉住服务层的读口真的接到存储
|
||
// (网关的可选接口 `backthinkTriggerLister` 靠的就是这个方法签名)。
|
||
//
|
||
// 变异:把 `Backthink.ListByTrigger` 改成忽略 trigger 直接 List ⇒ 本用例变红。
|
||
func TestBackthink_ListByTrigger_DelegatesToStore(t *testing.T) {
|
||
t.Parallel()
|
||
b, _ := newBackthink(t, Config{}, Deps{Thinker: newWakeThinker(), State: &mockState{}})
|
||
ctx := context.Background()
|
||
for i := 0; i < 3; i++ {
|
||
if _, err := b.store.Append(ctx, Record{Trigger: HeartbeatSource, At: time.UnixMilli(int64(i))}); err != nil {
|
||
t.Fatalf("Append: %v", err)
|
||
}
|
||
}
|
||
if _, err := b.store.Append(ctx, Record{Trigger: SelfWakeSource, At: time.UnixMilli(99)}); err != nil {
|
||
t.Fatalf("Append: %v", err)
|
||
}
|
||
|
||
got, err := b.ListByTrigger(ctx, SelfWakeSource, 50)
|
||
if err != nil {
|
||
t.Fatalf("ListByTrigger: %v", err)
|
||
}
|
||
if len(got) != 1 || got[0].Trigger != SelfWakeSource {
|
||
t.Fatalf("got = %+v, want 只有那条自排记录", got)
|
||
}
|
||
}
|