Files
Cyrene_v2/internal/services/gateway/admin_backthink_urgency_test.go
T
AskaEth 34cef95e17 feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env)
- 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」
- 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事)
- 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了)
- 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过
- 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
2026-10-05 13:54:56 +08:00

169 lines
6.9 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 gateway
import (
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件是 P4「后台思考行紧急度 / 穿透安静时段」的测试:钉住
// 「穿透标记 ⇔ 档位下限」的对应关系,以及「不可证时不下发」的诚实边界。
// quietCfg 构造安静时段 = 23:00-07:00(跨午夜)的生效配置。
func quietCfg() *config.Config {
cfg := cfgDefault()
cfg.Background.QuietStartHour = 23
cfg.Background.QuietEndHour = 7
return cfg
}
// localAt 造一个**本地时区**的墙钟时刻(安静时段是本地钟点窗口)。
func localAt(hour int) time.Time {
return time.Date(2026, 10, 6, hour, 30, 0, 0, time.Local)
}
// TestAdminBackthink_QuietHoursBypassMatchesUrgency 是判据 ④:管理行上的穿透标记
// 必须与 `urgency` 档位下限一一对应 ——
// - 安静时段内 + 判定要推 → urgency=warning(≥ high)+ quietHoursBypassed=true;
// - 安静时段内 + 判定不推 → urgency=routine + 无穿透键;
// - 安静时段外 → 两个键都不下发(存档不可证,绝不猜)。
func TestAdminBackthink_QuietHoursBypassMatchesUrgency(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{
ID: 3, Trigger: "iot.device.smoke", At: localAt(2), Conclusion: "烟雾告警,得叫她",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "delivered to 1 active client(s), event_bypass"},
},
{
ID: 2, Trigger: "heartbeat", At: localAt(3), Conclusion: "深夜只是想一想",
ShouldPush: false,
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "quiet hours; not interrupting"},
},
{
ID: 1, Trigger: "heartbeat", At: localAt(12), Conclusion: "白天的一轮",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteIMBridge, Detail: "delivered via IM bridge"},
},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
if len(items) != 3 {
t.Fatalf("items = %d, want 3", len(items))
}
body := string(raw)
byID := map[string]AdminBackthinkItem{}
for _, it := range items {
byID[it.ID] = it
}
// ① 安静时段内被放行 ⇒ 穿透 + 档位下限 warning(= 至少 high)。
bypass := byID["3"]
if !bypass.QuietHoursBypassed || bypass.Urgency != urgency.LevelWarning.String() {
t.Fatalf("穿透行 = %+v, want urgency=%s + quietHoursBypassed=true", bypass, urgency.LevelWarning)
}
// ② 安静时段内被扣下 ⇒ routine,且不得标成穿透。
held := byID["2"]
if held.QuietHoursBypassed || held.Urgency != urgency.LevelRoutine.String() {
t.Fatalf("静默行 = %+v, want urgency=routine 且不穿透", held)
}
// ③ 安静时段之外 ⇒ 两个键都不下发(存档不可证)。
day := byID["1"]
if day.QuietHoursBypassed || day.Urgency != "" {
t.Fatalf("白天行 = %+v, want 两个键都缺席", day)
}
// 线上形状:真穿透的行必须带这两个键;不可证的行必须**不带**它们(不是 false)。
if !strings.Contains(body, `"urgency":"warning"`) || !strings.Contains(body, `"quietHoursBypassed":true`) {
t.Fatalf("响应缺少穿透/档位键:%s", body)
}
if !strings.Contains(body, `"urgency":"routine"`) {
t.Fatalf("响应缺少被扣下那轮的档位:%s", body)
}
if strings.Contains(body, `"quietHoursBypassed":false`) {
t.Fatalf("「没穿透」不得写成 false(键必须缺席,才不把「未知」画成事实):%s", body)
}
}
// TestAdminBackthink_OmitsUrgencyForZeroTimestamp 钉住兜底:时间戳坏掉的行
// (backthink 存储层解析失败会给零值时间)**不下发**任何档位/穿透 ——
// 宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
func TestAdminBackthink_OmitsUrgencyForZeroTimestamp(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{ID: 1, Trigger: "broken", ShouldPush: true},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
got := items[0]
if got.Urgency != "" || got.QuietHoursBypassed {
t.Fatalf("零值时间戳不得产出档位/穿透:%+v", got)
}
if strings.Contains(string(raw), `"urgency"`) || strings.Contains(string(raw), `"quietHoursBypassed"`) {
t.Fatalf("零值时间戳必须省略两个键:%s", raw)
}
}
// TestQuietHoursActiveAt_WindowSemantics 钉住窗口口径(与 backthink.decide 的
// inQuietHours 逐字一致):半开区间、可跨午夜、两端相等即关闭。
func TestQuietHoursActiveAt_WindowSemantics(t *testing.T) {
t.Parallel()
at := func(hour, minute int) time.Time {
return time.Date(2026, 10, 6, hour, minute, 0, 0, time.Local)
}
cases := []struct {
name string
start, end int
when time.Time
want bool
}{
{"跨午夜:起点", 23, 7, at(23, 0), true},
{"跨午夜:午前", 23, 7, at(2, 30), true},
{"跨午夜:终点前", 23, 7, at(6, 59), true},
{"跨午夜:终点(半开区间不含)", 23, 7, at(7, 0), false},
{"跨午夜:起点前", 23, 7, at(22, 59), false},
{"跨午夜:白天", 23, 7, at(12, 0), false},
{"日间窗口", 9, 17, at(10, 0), true},
{"日间窗口:窗口外", 9, 17, at(22, 0), false},
{"两端相等 ⇒ 关闭", 23, 23, at(23, 30), false},
{"零值 ⇒ 关闭", 0, 0, at(1, 0), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := quietHoursActiveAt(tc.start, tc.end, tc.when); got != tc.want {
t.Fatalf("quietHoursActiveAt(%d,%d,%v) = %v, want %v", tc.start, tc.end, tc.when, got, tc.want)
}
})
}
}
// TestBackthinkQuietOutcomeOf_ReadsLocalHour 钉住时区口径:库里存 UTC,但安静
// 时段是**本地钟点**窗口 ⇒ 推导必须先换回本地时区再取钟点(否则北京时间凌晨 2 点
// 会被当成 UTC 18 点,穿透标记整片错位)。
func TestBackthinkQuietOutcomeOf_ReadsLocalHour(t *testing.T) {
t.Parallel()
if _, offset := time.Now().In(time.Local).Zone(); offset == 0 {
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
}
local2am := localAt(2)
utc := local2am.UTC()
if utc.Hour() == local2am.Hour() {
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
}
if got := backthinkQuietOutcomeOf(backthink.Record{At: utc, ShouldPush: true}, 23, 7); !got.bypassed {
t.Fatalf("UTC 存储的本地凌晨 2 点必须判为穿透:%+v", got)
}
}