Files
Cyrene_v2/internal/services/gateway/admin_channel_direct_limit_test.go
T
AskaEth 7aaf7731dc
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway): directMsgPerMin 的写入路径 5 处透传(关掉「面板保存把 -1 静默重置为 0」)
- 缺口:后端解析与面板都已就绪,但 gateway 没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」重置为 0=保护性默认 6 条/分
- 5 处:AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf 映射 / 微信扫码重建账号
- 语义零改动(只透传);变异实测:删拷贝 ⇒ channels.json 变 0(want -1)、删微信重建拷贝 ⇒ 同红、删 GET 映射 ⇒ 回显缺失
- 顺带登记一条负载诱发 flake(capability/plugin 的 TestManagerInstall_ConnectFailureDoesNotPersist,靠「端口被拒」断言,满载时端口被抢)⇒ 非本切片引入,已进 00-索引 §四
2026-10-05 14:01:52 +08:00

160 lines
6.5 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
// 本文件覆盖渠道账号**顶层限流字段 `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"])
}
}