Files
acfunlive-remake/notify_test.go
T
tamakyi be8ec5f953 通知推送拆成两个渠道:QQ 机器人和 MeoW,各自带开关和测试连通
用户要求两件事:① QQ bot 推送要能测连通、并加一个开关;② 照 MeoW 的接口
文档(https://www.chuckfang.com/MeoW/api_doc.html)加一个推送渠道,同样要有
测试和开关。确认过的三个决定:QQ bot 就是现有 webhook;事件勾选两个渠道
共用、开关按渠道分开;MeoW 只填一个昵称。

1) 通知推送改成「共享事件勾选 + 每渠道一个开关」
   - notify.go: notifySetting 扩成两个渠道,新增 qqReady()/meowReady();
     sendNotify 按渠道各自起后台 goroutine 分发,事件没勾选、或两个渠道都
     没启用时什么都不做
   - config.go: 新增 notifyEnabled(QQ 机器人渠道开关)、meowEnabled、
     meowNickname、meowMsgType、meowHtmlHeight、meowImgUrl
   - 关掉开关只是暂停推送,地址、昵称这些配置都留着,随时能再打开

2) 老配置的兼容(重要)
   - notifyEnabled 是普通 bool,老 config.json 里没有这个字段会解成 false,
     升级后会把已经配好的推送静默关掉
   - 解法:loadConfig() 里先置 true 再 json.Unmarshal(Unmarshal 只覆盖文件里
     出现的字段),默认值里也写 true;文件里显式写 false 的仍然会关掉
   - 已加回归测试 TestNotifyEnabledBackwardCompat 覆盖三种写法

3) MeoW 渠道(新增 meow.go)
   - 按文档走 POST {昵称},title/msg/url/imgUrl 放 json body,
     msgType/htmlHeight 是 query 参数(文档明确说这两个不属于 JSON 参数)
   - 文档自己前后矛盾:响应格式那节写 msg,示例写 message,两个字段都解析
   - 空字段一律不放进 body:body 里的 title 优先级高于路径参数,
     传空串会把 MeoW 的默认标题顶掉
   - 业务状态码翻成中文(400 参数错、403 内容被拦、404 昵称未注册、
     429 频控、500 服务器错误),失败信息直接显示在页面上
   - msgType 为 html 时渲染成 HTML(首行加粗、链接可点、内容转义),
     高度默认 200、范围 40-2000;事件正文复用 webhook 的那份 text

4) MeoW 的错误昵称会封 IP,所以加了保护(踩出来的)
   - 实测用不存在的昵称打真实接口,返回的提示是「一天之内累计3次输错对方
     昵称您的IP将被封禁」。而开播+开始录制+下播+合并完成一天轻松触发 4 次,
     昵称配错就会把用户 IP 封了
   - meow.go: 新增 meowBadNick 记录,pushMeow 收到 404 就记下这个昵称;
     sendNotify 和测试接口命中记录时直接跳过/拒绝并说明原因,
     改掉昵称或重启程序即恢复
   - 只有 404 触发拦截:429 是频控、400 是参数问题,都应该继续尝试
   - 已加测试 TestMeowBadNickGuard(404 要拦、429/400 不能拦、不影响别的昵称)

5) 测试连通
   - /api/notify/test 合并成一个接口,用 channel 区分 qq / meow,
     每个渠道的配置下面各有一个「测试连通」按钮
   - 用页面上当前填的值直接测、不用先保存,留空的项自动用已保存的值补齐;
     测试不受渠道开关和事件勾选影响(本来就是为了打开之前先确认配置)
   - 成功返回 {ok, detail},detail 就是页面上的结论:QQ 渠道显示 webhook 的
     HTTP 状态码和耗时,MeoW 渠道显示它返回的 status 和耗时;
     webhook 返回 200 只代表地址口令没问题,能不能收到要看机器人那边,
     这点也写在结果里
   - /api/status 新增 meowBlocked,页面上红字显示「当前推不出去:…」
     (昵称没填、格式不对、或已被判过未注册)

6) 页面
   - 通知区分成「事件勾选 / 渠道一 QQ 机器人 / 渠道二 MeoW」三块,
     每块用一条带标题的细线隔开,两个渠道各带开关和测试按钮
   - 消息类型选 text 时自动收起 HTML 高度那一行

另外:README 的通知推送一节重写(两个渠道的对比表、MeoW 的频控与封禁提醒、
测试连通的说明),配置文件详解补上新字段;doc/webapi.md 补 /api/notify/test
和 meowBlocked。doc/momoko-acfunlive-notify.py 本来就处理 event="test",不用改。

已验证:新增 notify_test.go,15 个用例全部通过(URL 拼接与转义、昵称校验、
消息类型与高度归一、HTML 渲染与转义、接口成功/失败/空字段处理、6 种业务状态码
的文案、404 拦截保护、老配置兼容、测试接口各分支),其中 TestSendNotifyFanOut
是端到端分发测试:两个渠道都开→各收到 1 条、关掉任一个→只剩另一个、
两个都关→0 条、事件没勾→0 条。真机冒烟两轮:页面控件齐全、老配置默认开启、
QQ 渠道实测本地 webhook 收到完整推送(含 Authorization 和 X-Auth-Token 头与群号)、
空值回退到已保存配置、6 种非法输入全 400、开关能落盘且关开关不清地址、
MeoW 真实接口返回的 404 被正确翻成中文。全量 34 个测试通过,
Windows/Linux 双平台构建、go vet、gofmt 全干净。
2026-09-25 13:42:49 +08:00

595 lines
20 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 main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// 把 MeoW 的接口地址临时换成本地测试服务器
func withMeowServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
old := meowAPIBase
meowAPIBase = srv.URL + "/"
t.Cleanup(func() {
meowAPIBase = old
srv.Close()
})
return srv
}
func TestCheckMeowNickname(t *testing.T) {
cases := []struct {
nick string
allow bool
wantOK bool
}{
{"JohnDoe", false, true},
{"小小怪", false, true},
{" JohnDoe ", false, true}, // 前后空白会被去掉
{"", true, true}, // 允许留空
{"", false, false}, // 要推送时不能为空
{"John/Doe", false, false},
{`John\Doe`, false, false},
{"John?Doe", false, false},
{"John#Doe", false, false},
{strings.Repeat("a", 65), false, false},
{strings.Repeat("a", 64), false, true},
}
for _, c := range cases {
got := checkMeowNickname(c.nick, c.allow)
if (got == "") != c.wantOK {
t.Errorf("checkMeowNickname(%q, %v) = %q,期望通过=%v", c.nick, c.allow, got, c.wantOK)
}
}
}
func TestNormalizeMeowMsgTypeAndHeight(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"", meowMsgTypeText},
{"text", meowMsgTypeText},
{"TEXT", meowMsgTypeText},
{" html ", meowMsgTypeHTML},
{"HTML", meowMsgTypeHTML},
{"markdown", meowMsgTypeText}, // 认不出来就回退
} {
if got := normalizeMeowMsgType(c.in); got != c.want {
t.Errorf("normalizeMeowMsgType(%q) = %q,期望 %q", c.in, got, c.want)
}
}
for _, c := range []struct{ in, want int }{
{0, defaultMeowHTMLHeight},
{-5, defaultMeowHTMLHeight},
{10, minMeowHTMLHeight},
{200, 200},
{99999, maxMeowHTMLHeight},
} {
if got := clampMeowHTMLHeight(c.in); got != c.want {
t.Errorf("clampMeowHTMLHeight(%d) = %d,期望 %d", c.in, got, c.want)
}
}
}
func TestMeowURL(t *testing.T) {
got := meowURL("小小怪", meowMsgTypeText, 200)
// text 类型不该带上 htmlHeight
if strings.Contains(got, "htmlHeight") {
t.Errorf("text 类型不该带 htmlHeight:%s", got)
}
if !strings.Contains(got, "msgType=text") {
t.Errorf("缺少 msgType:%s", got)
}
// 中文昵称必须转义,不能原样进地址
if strings.Contains(got, "小小怪") {
t.Errorf("昵称没做 URL 转义:%s", got)
}
if !strings.HasSuffix(got, "/%E5%B0%8F%E5%B0%8F%E6%80%AA?msgType=text") {
t.Errorf("昵称转义结果不对:%s", got)
}
got = meowURL("JohnDoe", meowMsgTypeHTML, 350)
if !strings.Contains(got, "msgType=html") || !strings.Contains(got, "htmlHeight=350") {
t.Errorf("html 类型的参数不对:%s", got)
}
// 超出范围的高度要夹住
if got := meowURL("JohnDoe", meowMsgTypeHTML, 99999); !strings.Contains(got, "htmlHeight=2000") {
t.Errorf("高度没夹住:%s", got)
}
}
func TestMeowHTMLText(t *testing.T) {
got := meowHTMLText("Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1\n<i>不该被解析</i>")
for _, want := range []string{
"<p><b>Xsphere 开播了</b></p>", // 第一行加粗
"<p>今天画点啥</p>", // 普通行
`<p><a href="https://live.acfun.cn/1">https://live.acfun.cn/1</a></p>`, // 链接可点
"&lt;i&gt;不该被解析&lt;/i&gt;", // 内容要转义
} {
if !strings.Contains(got, want) {
t.Errorf("HTML 里缺少 %q,实际:%s", want, got)
}
}
if strings.Contains(got, "<i>") {
t.Errorf("内容没被转义,存在注入风险:%s", got)
}
if got := meowHTMLText(" "); got != "" {
t.Errorf("空内容应该得到空串,实际 %q", got)
}
}
// 成功路径:确认请求本身拼对了,并且能正确解析文档里的两种响应字段(message / msg)
func TestPushMeowSuccess(t *testing.T) {
var gotPath, gotQuery, gotMethod, gotType string
var gotBody map[string]string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery, gotMethod = r.URL.Path, r.URL.RawQuery, r.Method
gotType = r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
err := pushMeow("小小怪", "开播提醒", "Xsphere 开播了", "https://live.acfun.cn/1", "https://x/icon.png",
meowMsgTypeHTML, 350)
if err != nil {
t.Fatalf("推送失败:%v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("方法应该是 POST,实际 %s", gotMethod)
}
if gotPath != "/小小怪" {
t.Errorf("路径不对:%q", gotPath)
}
if !strings.Contains(gotQuery, "msgType=html") || !strings.Contains(gotQuery, "htmlHeight=350") {
t.Errorf("查询参数不对:%q", gotQuery)
}
if !strings.HasPrefix(gotType, "application/json") {
t.Errorf("Content-Type 不对:%q", gotType)
}
for k, want := range map[string]string{
"msg": "Xsphere 开播了",
"title": "开播提醒",
"url": "https://live.acfun.cn/1",
"imgUrl": "https://x/icon.png",
} {
if gotBody[k] != want {
t.Errorf("body 里的 %s = %q,期望 %q", k, gotBody[k], want)
}
}
}
// 空字段不能进 body:body 里的 title 优先级比路径参数高,传空串会把默认标题顶掉
func TestPushMeowOmitsEmptyFields(t *testing.T) {
var gotBody map[string]any
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
if err := pushMeow("JohnDoe", "", "只有正文", "", "", meowMsgTypeText, 0); err != nil {
t.Fatalf("推送失败:%v", err)
}
if len(gotBody) != 1 || gotBody["msg"] != "只有正文" {
t.Errorf("应该只发 msg 一个字段,实际 %v", gotBody)
}
}
// 失败路径:文档里 HTTP 状态一律是 200,成败只能看 body 里的 status
func TestPushMeowStatusCodes(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"昵称未注册", `{"status":404,"message":"昵称未注册"}`, "昵称未注册"},
{"频率超限", `{"status":429,"msg":"too many requests"}`, "频率限制"},
{"内容被拦", `{"status":403,"message":"forbidden"}`, "策略"},
{"参数错误", `{"status":400,"message":"bad request"}`, "参数错误"},
{"服务器错误", `{"status":500,"msg":"服务器错误"}`, "MeoW 服务器错误"},
{"不是 json", `<!doctype html><html>502</html>`, "不是合法 json"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(c.body))
})
err := pushMeow("JohnDoe", "标题", "正文", "", "", meowMsgTypeText, 0)
if err == nil {
t.Fatalf("status 不是 200 时必须报错")
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("错误信息里应包含 %q,实际:%v", c.want, err)
}
})
}
}
func TestPushMeowRejectsBadInput(t *testing.T) {
// 昵称不合法时必须在发请求前就拦下来
if err := pushMeow("bad/nick", "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
t.Error("昵称带斜杠应该报错")
}
if err := pushMeow("JohnDoe", "标题", " ", "", "", meowMsgTypeText, 0); err == nil {
t.Error("内容为空应该报错")
}
}
// MeoW 对错误昵称记数并封 IP,所以 404 之后必须停止重试,否则事件反复触发会把用户 IP 封掉
func TestMeowBadNickGuard(t *testing.T) {
const nick = "guard_test_nick"
resetMeowBadNick()
t.Cleanup(resetMeowBadNick)
if got := meowBadNickReason(nick); got != "" {
t.Fatalf("一开始不该有记录,实际 %q", got)
}
if got := meowNickProblem(nick); got != "" {
t.Fatalf("没被封之前应该没问题,实际 %q", got)
}
// MeoW 返回 404 后,这个昵称要被记住
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"status":404,"message":"昵称未注册"}`))
})
if err := pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
t.Fatal("404 时应该报错")
}
reason := meowBadNickReason(nick)
if reason == "" {
t.Fatal("404 之后应该记下这个不可用的昵称")
}
if !strings.Contains(reason, "未注册") {
t.Errorf("记录的原因不对:%q", reason)
}
// 之后再推同一个昵称就该被拦住了
if got := meowNickProblem(nick); got != reason {
t.Errorf("meowNickProblem 应该返回被拦的原因,实际 %q", got)
}
// 两者都不该影响别的昵称
if got := meowBadNickReason("another_nick"); got != "" {
t.Errorf("别的昵称不该被牵连,实际 %q", got)
}
// meowReady 要据此判定推不出去
n := notifySetting{meowEnabled: true, meowNick: nick}
if n.meowReady() {
t.Error("昵称被判过不存在时 meowReady 应该是 false")
}
if n.meowBlockedReason() != reason {
t.Errorf("meowBlockedReason 应返回被拦原因,实际 %q", n.meowBlockedReason())
}
// 4xx / 5xx 里只有 404 会触发拦截,429 和 400 不该记进去
resetMeowBadNick()
for _, body := range []string{`{"status":429,"message":"限流"}`, `{"status":400,"message":"参数错"}`} {
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
})
_ = pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0)
if got := meowBadNickReason(nick); got != "" {
t.Errorf("status %s 不该触发拦截,实际记成了 %q", body, got)
}
}
}
// 被拦之后,测试接口要直接拒绝并说清原因,而不是又打一次接口
func TestNotifyTestHandlerMeowBlocked(t *testing.T) {
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
resetMeowBadNick()
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
resetMeowBadNick()
}()
*configDir = t.TempDir()
configFileLocation = filepath.Join(*configDir, configFile)
markMeowBadNick("blocked_nick", meowStatusText(404))
req := httptest.NewRequest(http.MethodPost, "/api/notify/test",
strings.NewReader(`{"channel":"meow","nickname":"blocked_nick"}`))
rec := httptest.NewRecorder()
notifyTestHandler(rec, req)
if rec.Code != 400 {
t.Errorf("被拦的昵称应该返回 400,实际 %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "不再重试") {
t.Errorf("提示要说明不会再重试,实际:%s", body)
}
}
// 端到端:sendNotify 要按事件勾选和两个渠道开关正确分发
func TestSendNotifyFanOut(t *testing.T) {
savedConfig := config
resetMeowBadNick()
defer func() {
config = savedConfig
resetMeowBadNick()
}()
var mu sync.Mutex
var hooks []notifyPayload
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p notifyPayload
_ = json.NewDecoder(r.Body).Decode(&p)
mu.Lock()
hooks = append(hooks, p)
mu.Unlock()
_, _ = w.Write([]byte("ok"))
}))
defer webhook.Close()
var meows []map[string]string
var meowQuery []string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
meows = append(meows, body)
meowQuery = append(meowQuery, r.URL.RawQuery)
mu.Unlock()
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
config.NotifyURL = webhook.URL
config.NotifyToken = "hook-token"
config.NotifyGroup = "777163254"
config.NotifyLiveOn = true
config.MeowEnabled = true
config.MeowNickname = "小小怪"
config.MeowMsgType = "text"
counts := func() (int, int) {
mu.Lock()
defer mu.Unlock()
return len(hooks), len(meows)
}
waitFor := func(tag string, wantHook, wantMeow int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
h, m := counts()
if h >= wantHook && m >= wantMeow {
return
}
time.Sleep(20 * time.Millisecond)
}
h, m := counts()
t.Fatalf("%s:等超时了,webhook %d 条(期望 %d)、MeoW %d 条(期望 %d)", tag, h, wantHook, m, wantMeow)
}
// 两个渠道都开:应该各收到一条
config.NotifyEnabled = true
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "",
"Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1")
waitFor("两个渠道都开", 1, 1)
mu.Lock()
h := hooks[0]
m := meows[0]
q := meowQuery[0]
mu.Unlock()
if h.Event != notifyEventLiveOn || h.Group != "777163254" || h.UID != 280178 {
t.Errorf("webhook 收到的内容不对:%+v", h)
}
if m["msg"] != "Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1" {
t.Errorf("MeoW 正文不对:%q", m["msg"])
}
if m["title"] != "开播提醒" {
t.Errorf("MeoW 标题不对:%q", m["title"])
}
if m["url"] != "https://live.acfun.cn/1" {
t.Errorf("MeoW 跳转链接不对:%q", m["url"])
}
if !strings.Contains(q, "msgType=text") {
t.Errorf("MeoW 查询参数不对:%q", q)
}
// 关掉 QQ 渠道,只剩 MeoW
config.NotifyEnabled = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "再来一条")
waitFor("只开 MeoW", 1, 2)
// 关掉 MeoW,只剩 QQ
config.NotifyEnabled = true
config.MeowEnabled = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "第三条")
waitFor("只开 QQ", 2, 2)
// 两个都关,一条都不该发
config.NotifyEnabled = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "不该发出")
time.Sleep(300 * time.Millisecond)
if h, m := counts(); h != 2 || m != 2 {
t.Errorf("两个渠道都关掉后不该再推送,实际 webhook %d 条、MeoW %d 条", h, m)
}
// 事件没勾选时也不该发(渠道都开着)
config.NotifyEnabled = true
config.MeowEnabled = true
config.NotifyLiveOn = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "事件没勾")
time.Sleep(300 * time.Millisecond)
if h, m := counts(); h != 2 || m != 2 {
t.Errorf("事件没勾选时不该推送,实际 webhook %d 条、MeoW %d 条", h, m)
}
}
func TestNotifyEventTitle(t *testing.T) {
for event, want := range map[string]string{
notifyEventLiveOn: "开播提醒",
notifyEventLiveOff: "下播提醒",
notifyEventRecordStart: "开始录制",
notifyEventMergeDone: "合并完成",
} {
if got := notifyEventTitle(event); got != want {
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
}
}
}
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
func TestNotifySettingChannelReady(t *testing.T) {
cases := []struct {
name string
n notifySetting
wantQQ, wantM bool
}{
{"都没开", notifySetting{}, false, false},
{"QQ 开了但没填地址", notifySetting{qqEnabled: true}, false, false},
{"QQ 开了且填了地址", notifySetting{qqEnabled: true, url: "http://x/y"}, true, false},
{"QQ 填了地址但没开", notifySetting{url: "http://x/y"}, false, false},
{"MeoW 开了但没昵称", notifySetting{meowEnabled: true}, false, false},
{"MeoW 开了且有昵称", notifySetting{meowEnabled: true, meowNick: "JohnDoe"}, false, true},
{"MeoW 昵称非法", notifySetting{meowEnabled: true, meowNick: "bad/nick"}, false, false},
{"两个都开了", notifySetting{qqEnabled: true, url: "http://x/y", meowEnabled: true, meowNick: "JohnDoe"}, true, true},
}
for _, c := range cases {
if got := c.n.qqReady(); got != c.wantQQ {
t.Errorf("%s:qqReady = %v,期望 %v", c.name, got, c.wantQQ)
}
if got := c.n.meowReady(); got != c.wantM {
t.Errorf("%s:meowReady = %v,期望 %v", c.name, got, c.wantM)
}
}
}
// 老设置文件里没有 notifyEnabled,升级后不能把已经配好的推送静默关掉
func TestNotifyEnabledBackwardCompat(t *testing.T) {
dir := t.TempDir()
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
write := func(content string) {
t.Helper()
if err := os.WriteFile(configFileLocation, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// 老配置:完全没提 notifyEnabled,但已经配好了地址
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyLiveOn":true}`)
loadConfig()
if !config.NotifyEnabled {
t.Errorf("老配置里没有 notifyEnabled 时应该默认开启,实际关闭了")
}
if config.NotifyURL == "" || !config.NotifyLiveOn {
t.Errorf("其它字段被破坏:url=%q liveOn=%v", config.NotifyURL, config.NotifyLiveOn)
}
// 显式写了 true
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":true}`)
loadConfig()
if !config.NotifyEnabled {
t.Errorf("显式写 true 时应该是开启")
}
// 显式写了 false,必须真的关掉
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":false}`)
loadConfig()
if config.NotifyEnabled {
t.Errorf("显式写 false 时应该是关闭")
}
}
// 测试接口:没填地址或昵称时给 400,填了就往对应渠道发
func TestNotifyTestHandler(t *testing.T) {
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = t.TempDir()
configFileLocation = filepath.Join(*configDir, configFile)
var webhookHits int
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
webhookHits++
_, _ = w.Write([]byte("ok"))
}))
defer webhook.Close()
var meowNick string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
meowNick = strings.TrimPrefix(r.URL.Path, "/")
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
call := func(body string) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/notify/test", strings.NewReader(body))
rec := httptest.NewRecorder()
notifyTestHandler(rec, req)
return rec.Code, rec.Body.String()
}
// 什么都没配
config.NotifyURL, config.MeowNickname = "", ""
if code, msg := call(`{"channel":"qq"}`); code != 400 || !strings.Contains(msg, "推送地址") {
t.Errorf("没填地址应该 400,实际 %d %s", code, msg)
}
if code, msg := call(`{"channel":"meow"}`); code != 400 || !strings.Contains(msg, "昵称") {
t.Errorf("没填昵称应该 400,实际 %d %s", code, msg)
}
if code, msg := call(`{"channel":"whatever"}`); code != 400 || !strings.Contains(msg, "未知的推送渠道") {
t.Errorf("未知渠道应该 400,实际 %d %s", code, msg)
}
// 用页面上的值直接测 webhook,不需要先保存
code, msg := call(`{"channel":"qq","url":"` + webhook.URL + `","group":"777163254"}`)
if code != 200 || !strings.Contains(msg, `"ok":true`) || !strings.Contains(msg, "HTTP 200") {
t.Errorf("QQ 渠道测试应该成功,实际 %d %s", code, msg)
}
if webhookHits != 1 {
t.Errorf("webhook 应该被调用 1 次,实际 %d 次", webhookHits)
}
// MeoW 渠道
code, msg = call(`{"channel":"meow","nickname":"小小怪","msgType":"html","htmlHeight":300}`)
if code != 200 || !strings.Contains(msg, "status 200") {
t.Errorf("MeoW 渠道测试应该成功,实际 %d %s", code, msg)
}
if meowNick != "小小怪" {
t.Errorf("MeoW 收到的昵称是 %q,期望 小小怪", meowNick)
}
// 昵称带斜杠要被拦下(否则会打到别的路径上去)
if code, _ := call(`{"channel":"meow","nickname":"bad/nick"}`); code != 400 {
t.Errorf("非法昵称应该 400,实际 %d", code)
}
}