Files
Cyrene_v2/internal/services/gateway/notifications_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

358 lines
13 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 (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// stubAuditFeed 是一个基于脚本化记录的 AuditStore(已按最新优先),
// 使 feed 测试能精确控制毫秒时间戳 —— 内存
// 回退存储只保留秒级精度。
type stubAuditFeed struct {
records []AuditRecord
err error
}
func (s *stubAuditFeed) Append(context.Context, string, string, string, time.Time) error { return nil }
func (s *stubAuditFeed) List(_ context.Context, limit int) ([]AuditRecord, error) {
if s.err != nil {
return nil, s.err
}
if limit <= 0 || limit > len(s.records) {
limit = len(s.records)
}
return append([]AuditRecord(nil), s.records[:limit]...), nil
}
// stubBackthinkFeed 是一个基于脚本化记录的 BackthinkStore(最新优先)。
type stubBackthinkFeed struct {
records []backthink.Record
err error
}
func (s *stubBackthinkFeed) List(_ context.Context, limit int) ([]backthink.Record, error) {
if s.err != nil {
return nil, s.err
}
if limit <= 0 || limit > len(s.records) {
limit = len(s.records)
}
return append([]backthink.Record(nil), s.records[:limit]...), nil
}
// notificationMS 把 epoch 毫秒渲染成审计
// 记录所携带的 RFC3339 时间戳。
func notificationMS(ms int64) string {
return time.UnixMilli(ms).UTC().Format(time.RFC3339Nano)
}
// notificationFeedFixture 脚本化两个来源:审计链中的
// channel + system + audit 行,以及一条已投递 / 一条未投递 / 一条非推送的
// 后台思考记录。
func notificationFeedFixture() (*stubAuditFeed, *stubBackthinkFeed) {
audit := &stubAuditFeed{records: []AuditRecord{
{ID: 5, Who: "owner:owner", What: "channel.open", When: notificationMS(3000), Result: "ok"},
{ID: 4, Who: "bridge:telegram", What: "channel.reject", When: notificationMS(2900), Result: "群聊白名单外"},
{ID: 3, Who: "owner:owner", What: "system.backup", When: notificationMS(2000), Result: "ok"},
{ID: 2, Who: "owner:owner", What: "persona.update", When: notificationMS(1000), Result: "ok"},
{ID: 1, Who: "owner:owner", What: "plugin.install", When: notificationMS(500), Result: "error"},
}}
bt := &stubBackthinkFeed{records: []backthink.Record{
{ID: 8, Trigger: "idle", At: time.UnixMilli(2600), Conclusion: "该喝水了", ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true}},
{ID: 7, Trigger: "event", At: time.UnixMilli(2400), Conclusion: "只是想了一下", ShouldPush: false},
{ID: 6, Trigger: "heartbeat", At: time.UnixMilli(1500), Conclusion: strings.Repeat("很长的结论", 60), ShouldPush: true},
}}
return audit, bt
}
// listNotifications 调用该端点并解码 feed。
func listNotifications(t *testing.T, ts *httptest.Server, tok, query string) (int, []AdminNotification, []byte) {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/notifications"+query, nil, tok)
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, nil, body
}
var out []AdminNotification
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode notifications %s: %v", body, err)
}
return resp.StatusCode, out, body
}
// TestAdminListNotifications_MergesAuditAndBackthink 证明 feed 是两个
// 既有来源按最新优先合并、id 带来源命名空间、审计
// 族分类正确,以及决定不推送的后台思考记录
// 不是事件。
func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
_, ts, jwt := buildEnv(t, nil, Deps{AuditStore: audit, Backthink: bt})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, items, body := listNotifications(t, ts, tok, "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
wantOrder := []struct {
id string
typ string
src string
when int64
}{
{"audit:5", NotificationTypeChannel, notificationSourceAudit, 3000},
{"audit:4", NotificationTypeChannel, notificationSourceAudit, 2900},
{"backthink:8", NotificationTypeBackthink, notificationSourceBackthink, 2600},
{"audit:3", NotificationTypeSystem, notificationSourceAudit, 2000},
{"backthink:6", NotificationTypeBackthink, notificationSourceBackthink, 1500},
{"audit:2", NotificationTypeAudit, notificationSourceAudit, 1000},
{"audit:1", NotificationTypeSystem, notificationSourceAudit, 500},
}
if len(items) != len(wantOrder) {
t.Fatalf("feed = %d items, want %d (%+v)", len(items), len(wantOrder), items)
}
for i, want := range wantOrder {
got := items[i]
if got.ID != want.id || got.Type != want.typ || got.Source != want.src || got.CreatedAt != want.when {
t.Fatalf("feed[%d] = %+v, want {id:%s type:%s source:%s createdAt:%d}",
i, got, want.id, want.typ, want.src, want.when)
}
if got.Title == "" {
t.Fatalf("feed[%d] has no title: %+v", i, got)
}
if want.id == "backthink:8" && !strings.Contains(got.Title, "已送达") {
t.Fatalf("backthink title = %q, want the delivery state", got.Title)
}
}
// 未投递的推送更旧且其结论很长:body 上限
// 与「未送达」标签都必须体现。
for _, it := range items {
if it.ID == "backthink:6" {
if !strings.Contains(it.Title, "未送达") {
t.Fatalf("backthink title = %q, want 未送达 for an undelivered push", it.Title)
}
if len([]rune(it.Body)) > notificationBodyRunes+1 {
t.Fatalf("body = %d runes, want <= %d (+ellipsis)", len([]rune(it.Body)), notificationBodyRunes)
}
}
}
// 决定不推送的思考记录绝不成为通知。
for _, it := range items {
if it.ID == "backthink:7" {
t.Fatalf("non-push backthink record leaked into the feed: %+v", it)
}
}
}
// TestAdminListNotifications_TypeFilter 证明 `?type=` 选中一个族,
// 且没有任何来源数据的族返回空 feed(绝不编造
// 一行)。
func TestAdminListNotifications_TypeFilter(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
_, ts, jwt := buildEnv(t, nil, Deps{AuditStore: audit, Backthink: bt})
tok := signAny(t, jwt, "acct", auth.RoleAdmin)
cases := []struct {
query string
want []string
}{
{"?type=channel", []string{"audit:5", "audit:4"}},
{"?type=backthink", []string{"backthink:8", "backthink:6"}},
{"?type=system", []string{"audit:3", "audit:1"}},
{"?type=audit", []string{"audit:2"}},
}
for _, tc := range cases {
status, items, body := listNotifications(t, ts, tok, tc.query)
if status != http.StatusOK {
t.Fatalf("%s status = %d, want 200 (body=%s)", tc.query, status, body)
}
if len(items) != len(tc.want) {
t.Fatalf("%s = %+v, want %v", tc.query, items, tc.want)
}
for i, want := range tc.want {
if items[i].ID != want {
t.Fatalf("%s[%d] = %s, want %s", tc.query, i, items[i].ID, want)
}
}
}
// 空来源(未注入后台思考服务)得到空族。
_, auditOnly, jwtOnly := buildEnv(t, nil, Deps{AuditStore: audit})
tokAudit := signAny(t, jwtOnly, "acct", auth.RoleOwner)
status, items, body := listNotifications(t, auditOnly, tokAudit, "?type=backthink")
if status != http.StatusOK || len(items) != 0 {
t.Fatalf("no-backthink feed = %d items / status %d, want 0 / 200 (body=%s)", len(items), status, body)
}
}
// TestAdminListNotifications_BeforeCursorPagination 证明 `before` 游标:
// 翻页向更旧走,游标条目本身被包含(来源解析到
// 毫秒,客户端按 id 去重),且 `limit` 限定一页。
func TestAdminListNotifications_BeforeCursorPagination(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
_, ts, jwt := buildEnv(t, nil, Deps{AuditStore: audit, Backthink: bt})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, page1, body := listNotifications(t, ts, tok, "?limit=2")
if status != http.StatusOK {
t.Fatalf("page1 status = %d, want 200 (body=%s)", status, body)
}
if len(page1) != 2 || page1[0].ID != "audit:5" || page1[1].ID != "audit:4" {
t.Fatalf("page1 = %+v, want [audit:5 audit:4]", page1)
}
status, page2, body := listNotifications(t, ts, tok, "?limit=2&before=2900")
if status != http.StatusOK {
t.Fatalf("page2 status = %d, want 200 (body=%s)", status, body)
}
if len(page2) != 2 || page2[0].ID != "audit:4" || page2[1].ID != "backthink:8" {
t.Fatalf("page2 = %+v, want [audit:4 backthink:8] (cursor inclusive)", page2)
}
status, page3, body := listNotifications(t, ts, tok, "?limit=2&before=2600")
if status != http.StatusOK {
t.Fatalf("page3 status = %d, want 200 (body=%s)", status, body)
}
if len(page3) != 2 || page3[0].ID != "backthink:8" || page3[1].ID != "audit:3" {
t.Fatalf("page3 = %+v, want [backthink:8 audit:3]", page3)
}
// 比所有条目都旧的游标以空页结束翻页。
status, tail, body := listNotifications(t, ts, tok, "?before=1")
if status != http.StatusOK || len(tail) != 0 {
t.Fatalf("tail = %+v / status %d, want empty / 200 (body=%s)", tail, status, body)
}
}
// TestAdminListNotifications_NoSourcesNoFabrication 证明诚实的空 feed:
// 两个来源都未注入时端点返回空数组,而不是编造
// 通知。
func TestAdminListNotifications_NoSourcesNoFabrication(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, nil, Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, items, body := listNotifications(t, ts, tok, "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
if len(items) != 0 {
t.Fatalf("unwired feed = %+v, want empty", items)
}
if got := strings.TrimSpace(string(decodeBody(t, body)["data"])); got != "[]" {
t.Fatalf("data = %s, want []", got)
}
}
// TestAdminListNotifications_Errors 证明该端点响亮地失败:未知
// 族是 400,来源失败是 500(绝不是静默变短的
// feed)。viewer 可以读该 feed(只读监控层)。
func TestAdminListNotifications_Errors(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
_, ts, jwt := buildEnv(t, nil, Deps{AuditStore: audit, Backthink: bt})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, _, body := listNotifications(t, ts, tok, "?type=bogus")
if status != http.StatusBadRequest {
t.Fatalf("unknown type status = %d, want 400 (body=%s)", status, body)
}
broken := &stubAuditFeed{err: errors.New("audit exploded")}
_, brokenTS, brokenJWT := buildEnv(t, nil, Deps{AuditStore: broken, Backthink: bt})
status, _, body = listNotifications(t, brokenTS, signAny(t, brokenJWT, "acct", auth.RoleOwner), "")
if status != http.StatusInternalServerError {
t.Fatalf("broken source status = %d, want 500 (body=%s)", status, body)
}
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
if status, _, _ := listNotifications(t, ts, viewer, ""); status != http.StatusOK {
t.Fatalf("viewer status = %d, want 200", status)
}
}
// TestNotificationAuditAt_ParsesBothStoreFormats 用两个真实审计存储锁定
// 时间戳契约:SQLiteAuditStore 写 RFC3339Nano,内存
// 回退写 RFC3339,无法解析的值退化到 0(最旧),而不是
// 被丢弃或伪造。
func TestNotificationAuditAt_ParsesBothStoreFormats(t *testing.T) {
t.Parallel()
base := time.Date(2026, 9, 30, 10, 0, 0, 123456000, time.UTC)
cases := []struct {
when string
want int64
}{
{base.Format(time.RFC3339Nano), base.UnixMilli()},
{base.Truncate(time.Second).Format(time.RFC3339), base.Truncate(time.Second).UnixMilli()},
{"2026-09-30T18:00:00+08:00", time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC).UnixMilli()},
{"", 0},
{"not-a-time", 0},
}
for _, tc := range cases {
if got := notificationAuditAt(tc.when); got != tc.want {
t.Fatalf("notificationAuditAt(%q) = %d, want %d", tc.when, got, tc.want)
}
}
}
// TestNotificationQueryParsing 锁定查询旋钮:limit 默认 50,
// 上限 200,且 `before` 缺失或畸形意味着「从最新开始」。
func TestNotificationQueryParsing(t *testing.T) {
t.Parallel()
limitCases := []struct {
raw string
want int
}{
{"", defaultNotificationLimit},
{"?limit=0", defaultNotificationLimit},
{"?limit=-3", defaultNotificationLimit},
{"?limit=abc", defaultNotificationLimit},
{"?limit=10", 10},
{"?limit=1000", maxNotificationLimit},
}
for _, tc := range limitCases {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/notifications"+tc.raw, nil)
if got := parseNotificationLimit(req); got != tc.want {
t.Fatalf("parseNotificationLimit(%q) = %d, want %d", tc.raw, got, tc.want)
}
}
beforeCases := []struct {
raw string
want int64
wantOK bool
}{
{"", 0, false},
{"?before=0", 0, false},
{"?before=-5", 0, false},
{"?before=abc", 0, false},
{"?before=1700000000000", 1700000000000, true},
}
for _, tc := range beforeCases {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/notifications"+tc.raw, nil)
got, ok := parseNotificationBefore(req)
if got != tc.want || ok != tc.wantOK {
t.Fatalf("parseNotificationBefore(%q) = %d,%v, want %d,%v", tc.raw, got, ok, tc.want, tc.wantOK)
}
}
if got := notificationFetchLimit(defaultNotificationLimit); got < defaultNotificationLimit || got > maxObsLimit {
t.Fatalf("notificationFetchLimit(50) = %d, want within [50, %d]", got, maxObsLimit)
}
if got := notificationFetchLimit(maxNotificationLimit); got != maxObsLimit {
t.Fatalf("notificationFetchLimit(200) = %d, want the %d cap", got, maxObsLimit)
}
}