package gateway // 本文件覆盖渠道账号**顶层限流字段 `directMsgPerMin`** 的写/读往返 —— // 「已关闭的流控被静默重置」回归(写入见 admin_write_channel_config.go 的 // DTO / 请求体 / config.ChannelAccount 拷贝 / adminChannelAccountOf, // 以及 admin_channels_weixin.go 的扫码重建账号)。 // // 语义(与 internal/services/channelbridge/flowgate.go 的 directLimit 逐字同口径): // // 键缺失 / 0 ⇒ 保护性默认 6 条/分(陌生人必须有流控,缺省绝不解释成「关闭」) // 正值 ⇒ 该值 // 负值 ⇒ 关闭该限制(主人的显式选择,桥会留一条带 source 的 WARN) // // 它在线上是**账号顶层**字段(不在 config 里),面板回显后把该值原样写回, // 因此两端任一处漏掉它,「已关闭」都会被静默变回「保护性默认 6 条/分」—— // 面板保存一次账号卡 = 主人明确关掉的限制被系统悄悄打开。 import ( "context" "io" "log/slog" "net/http" "path/filepath" "strconv" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // TestAdminSaveChannelConfigRoundTripsDirectMsgPerMin 是主回归:PUT 里给出的 // directMsgPerMin 必须**逐字**落到 channels.json(负值尤其不许被吞成 0), // 且 GET /admin/channels 把它如实回显给面板 —— 面板正是靠这次回显才知道 // 该字段「已关闭」,进而在下一次保存时把 -1 原样送回。 func TestAdminSaveChannelConfigRoundTripsDirectMsgPerMin(t *testing.T) { t.Parallel() for _, tc := range []struct { name string baseline int body string want int }{ { name: "已关闭(-1)再保存一次仍是 -1", baseline: -1, body: `{"id":"conf0","token":"bt","directMsgPerMin":-1}`, want: -1, }, { name: "原本是正值 5,改成 -1 关掉", baseline: 5, body: `{"id":"conf0","token":"bt","directMsgPerMin":-1}`, want: -1, }, { name: "原本已关闭,改成正值 4 重新打开", baseline: -1, body: `{"id":"conf0","token":"bt","directMsgPerMin":4}`, want: 4, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "bt"}, DirectMsgPerMin: tc.baseline}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) if resp, body := putChannelConfigRaw(t, ts, "kook", tc.body, tok); resp.StatusCode != http.StatusCreated { t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body) } // 盘上:channels.json 的账号顶层字段,就是请求体给的值。 acct, ok := storedChannelAccountOrNil(t, path, "kook", "conf0") if !ok { t.Fatal("kook/conf0 vanished from channels.json") } if acct.DirectMsgPerMin != tc.want { t.Fatalf("channels.json directMsgPerMin = %d, want %d —— 被吞成 0 就等于把关掉的限制"+ "静默重置回保护性默认 6 条/分", acct.DirectMsgPerMin, tc.want) } // 内存里的账号(热重载后适配器读的就是它)。 for _, live := range store.accounts["kook"] { if live.ID == "conf0" && live.DirectMsgPerMin != tc.want { t.Fatalf("in-memory directMsgPerMin = %d, want %d", live.DirectMsgPerMin, tc.want) } } // GET 如实回显(否则面板把它显示成「未设置」,下一次保存就把它抹成 0)。 _, listBody := adminChannelsList(t, ts, tok) if !strings.Contains(string(listBody), `"directMsgPerMin":`+strconv.Itoa(tc.want)) { t.Fatalf("GET /admin/channels did not echo directMsgPerMin=%d: %s", tc.want, listBody) } }) } } // TestAdminChannelsListOmitsUnsetDirectMsgPerMin 钉住 omitempty 约定:账号未设 // 该字段时键**缺席**(0 与「未设置」在线上同形是既有约定,不改)。对照组断言 // 该账号元素确实出现在响应里,使「不含该键」不是一句空话。 func TestAdminChannelsListOmitsUnsetDirectMsgPerMin(t *testing.T) { t.Parallel() store := &stubChannelConfigStore{ path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{ "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "bt"}, GroupMsgPerMin: 5}}, }, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) items, listBody := adminChannelsList(t, ts, tok) if len(items) != 1 || len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "conf0" { t.Fatalf("items = %+v, want a single kook/conf0 account", items) } if !strings.Contains(string(listBody), `"groupMsgPerMin":5`) { t.Fatalf("control field missing, the absence assertion would be vacuous: %s", listBody) } if strings.Contains(string(listBody), "directMsgPerMin") { t.Fatalf("未设置时该键必须缺席(omitempty 约定;0 与未设置线上同形): %s", listBody) } } // TestPersistWeixinConfirmedKeepsDirectMsgPerMin 覆盖第二条重建路径:微信扫码 // 确认后 handler 会把账号整体重建为 config.ChannelAccount 再 UpsertChannel, // 因此它必须把账号顶层的 directMsgPerMin(含 -1 = 关闭)一并拷过去 —— // 否则一次扫码保存会把主人关掉的陌生人私聊限制重新打开。 func TestPersistWeixinConfirmedKeepsDirectMsgPerMin(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") chs := &fakeWeixinChannels{ path: path, data: map[string][]config.ChannelAccount{ "weixin-ilink": {{ ID: "conf0", Config: map[string]any{"base_url": "https://base"}, DirectMsgPerMin: -1, }}, }, } s := &Server{channels: chs, log: slog.New(slog.NewTextHandler(io.Discard, nil))} if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-x", "bot-x", "o-1"); err != nil { t.Fatalf("persistWeixinConfirmed: %v", err) } acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "conf0") if !ok { t.Fatal("weixin-ilink/conf0 vanished from channels.json after the scan save") } if acct.DirectMsgPerMin != -1 { t.Fatalf("扫码重建账号把 directMsgPerMin 写成 %d, want -1(关掉的限制不许被重置)", acct.DirectMsgPerMin) } if acct.Config["bot_token"] != "tok-x" { t.Fatalf("config[bot_token] = %#v, want the scanned token persisted", acct.Config["bot_token"]) } }