package service import ( "bufio" "context" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" ) func TestRenderWebhookBody(t *testing.T) { msg := notifyMsg{Title: "任务失败", Text: "line1\nline\"2\""} tests := []struct { name string tpl string want string }{ { name: "空模板用缺省 Slack 形状", tpl: "", want: `{"text": "任务失败\nline1\nline\"2\""}`, }, { name: "自定义模板占位替换并转义", tpl: `{"msgtype":"text","text":{"content":"{{text}}"}}`, want: `{"msgtype":"text","text":{"content":"line1\nline\"2\""}}`, }, { name: "无占位原样输出", tpl: `{"fixed":1}`, want: `{"fixed":1}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := renderWebhookBody(tt.tpl, msg) if got != tt.want { t.Errorf("renderWebhookBody = %s, want %s", got, tt.want) } if !json.Valid([]byte(got)) { t.Errorf("渲染结果不是合法 JSON: %s", got) } }) } } // jsonCapture 收集一次 HTTP 请求的路径、认证头与 JSON body。 type jsonCapture struct { mu sync.Mutex path string auth string fields map[string]string } // newCaptureServer 起假渠道服务端,固定返回 status。 func newCaptureServer(t *testing.T, status int) (*httptest.Server, *jsonCapture) { t.Helper() rec := &jsonCapture{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.mu.Lock() defer rec.mu.Unlock() rec.path = r.URL.Path rec.auth = r.Header.Get("Authorization") rec.fields = map[string]string{} _ = json.NewDecoder(r.Body).Decode(&rec.fields) w.WriteHeader(status) })) t.Cleanup(srv.Close) return srv, rec } func TestSendNtfy(t *testing.T) { srv, rec := newCaptureServer(t, http.StatusOK) cfg := NtfyChannel{Server: srv.URL, Topic: "oci", Token: "tk-1"} msg := notifyMsg{Title: "标题", Text: "正文"} if err := sendNtfy(context.Background(), srv.Client(), cfg, msg); err != nil { t.Fatalf("sendNtfy: %v", err) } if rec.auth != "Bearer tk-1" { t.Errorf("Authorization = %q, want Bearer tk-1", rec.auth) } want := map[string]string{"topic": "oci", "title": "标题", "message": "正文"} for k, v := range want { if rec.fields[k] != v { t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v) } } } func TestSendBark(t *testing.T) { srv, rec := newCaptureServer(t, http.StatusOK) cfg := BarkChannel{Server: srv.URL, DeviceKey: "dk-1"} if err := sendBark(context.Background(), srv.Client(), cfg, notifyMsg{Title: "标题", Text: "正文"}); err != nil { t.Fatalf("sendBark: %v", err) } if rec.path != "/push" { t.Errorf("path = %q, want /push", rec.path) } want := map[string]string{"title": "标题", "body": "正文", "device_key": "dk-1"} for k, v := range want { if rec.fields[k] != v { t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v) } } } func TestPostJSONNon2xx(t *testing.T) { srv, _ := newCaptureServer(t, http.StatusBadGateway) err := postJSON(context.Background(), srv.Client(), srv.URL, "", []byte(`{}`)) if err == nil || !strings.Contains(err.Error(), "502") { t.Fatalf("postJSON err = %v, want contains 502", err) } } func TestValidNotifyURL(t *testing.T) { tests := []struct { raw string wantErr bool }{ {"https://example.com/hook", false}, {"http://10.0.0.1:8080/x", false}, {"ftp://example.com", true}, {"not-a-url", true}, {"", true}, } for _, tt := range tests { if err := validNotifyURL(tt.raw); (err != nil) != tt.wantErr { t.Errorf("validNotifyURL(%q) err = %v, wantErr %v", tt.raw, err, tt.wantErr) } } } // fakeSMTP 起一个最小 SMTP 服务端,按标准会话应答并收集 DATA 报文。 func fakeSMTP(t *testing.T) (addr string, got *strings.Builder) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } t.Cleanup(func() { ln.Close() }) got = &strings.Builder{} go func() { conn, err := ln.Accept() if err != nil { return } defer conn.Close() fmt.Fprint(conn, "220 fake ESMTP\r\n") r := bufio.NewReader(conn) for { line, err := r.ReadString('\n') if err != nil { return } cmd := strings.ToUpper(strings.TrimSpace(line)) switch { case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): fmt.Fprint(conn, "250-fake\r\n250 AUTH PLAIN\r\n") case strings.HasPrefix(cmd, "AUTH"): fmt.Fprint(conn, "235 ok\r\n") case strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"): fmt.Fprint(conn, "250 ok\r\n") case cmd == "DATA": fmt.Fprint(conn, "354 go\r\n") readSMTPData(r, got) fmt.Fprint(conn, "250 ok\r\n") case cmd == "QUIT": fmt.Fprint(conn, "221 bye\r\n") return default: fmt.Fprint(conn, "250 ok\r\n") } } }() return ln.Addr().String(), got } // readSMTPData 读取 DATA 段直到单独一行 "."。 func readSMTPData(r *bufio.Reader, got *strings.Builder) { for { line, err := r.ReadString('\n') if err != nil || strings.TrimRight(line, "\r\n") == "." { return } got.WriteString(line) } } func TestSendSMTP(t *testing.T) { addr, got := fakeSMTP(t) host, portStr, _ := net.SplitHostPort(addr) var port int fmt.Sscanf(portStr, "%d", &port) cfg := SMTPChannel{ Host: host, Port: port, Username: "u", Password: "p", From: "a@x.com", To: "b@y.com", } err := sendSMTP(context.Background(), cfg, notifyMsg{Title: "任务失败", Text: "第一行\n第二行"}) if err != nil { t.Fatalf("sendSMTP: %v", err) } mail := got.String() for _, want := range []string{"From: a@x.com", "To: b@y.com", "Subject: =?utf-8?q?", "第一行\r\n第二行"} { if !strings.Contains(mail, want) { t.Errorf("mail 缺少 %q:\n%s", want, mail) } } } // TestDispatchFanout 验证多渠道并发投递:一个渠道失败不影响其他渠道送达。 func TestDispatchFanout(t *testing.T) { okSrv, okRec := newCaptureServer(t, http.StatusOK) badSrv, badRec := newCaptureServer(t, http.StatusInternalServerError) settings, _ := newSettingEnv(t) ctx := context.Background() mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: true, URL: badSrv.URL}) mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Enabled: true, Server: okSrv.URL, Topic: "oci"}) n := NewNotifier(settings) err := n.dispatch(ctx, notifyMsg{Title: "T", Text: "body"}) if err == nil || !strings.Contains(err.Error(), "webhook") { t.Fatalf("dispatch err = %v, want webhook 失败", err) } if badRec.fields == nil { t.Error("webhook 渠道未收到请求") } if okRec.fields["message"] != "body" { t.Errorf("ntfy 渠道未正常送达: %v", okRec.fields) } } // TestDispatchSkipsDisabled 验证未启用/未配置的渠道静默跳过且不报错。 func TestDispatchSkipsDisabled(t *testing.T) { srv, rec := newCaptureServer(t, http.StatusOK) settings, _ := newSettingEnv(t) mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: false, URL: srv.URL}) n := NewNotifier(settings) if err := n.dispatch(context.Background(), notifyMsg{Title: "T", Text: "x"}); err != nil { t.Fatalf("dispatch: %v", err) } if rec.fields != nil { t.Error("未启用渠道不应收到请求") } } // mustUpdateChannel 保存渠道配置,失败即终止测试。 func mustUpdateChannel(t *testing.T, s *SettingService, chType string, in UpdateNotifyChannelInput) { t.Helper() if err := s.UpdateNotifyChannel(context.Background(), chType, in); err != nil { t.Fatalf("update channel %s: %v", chType, err) } } func TestUpdateNotifyChannelValidation(t *testing.T) { secret := "sec-123456" tests := []struct { name string chType string in UpdateNotifyChannelInput wantErr string }{ {name: "未知类型", chType: "pigeon", wantErr: "未知通知渠道"}, {name: "启用 webhook 缺 URL", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "URL"}, {name: "webhook 非法协议", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true, URL: "ftp://x"}, wantErr: "http(s)"}, {name: "启用 ntfy 缺 topic", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "topic"}, {name: "启用 smtp 缺主机", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Port: 587}, wantErr: "SMTP"}, {name: "smtp 端口越界", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Host: "h", Port: 70000, From: "a@x", To: "b@y"}, wantErr: "端口"}, {name: "未启用可存草稿", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: false}}, {name: "配置齐全可启用", chType: ChannelBark, in: UpdateNotifyChannelInput{Enabled: true, Secret: &secret}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { settings, _ := newSettingEnv(t) err := settings.UpdateNotifyChannel(context.Background(), tt.chType, tt.in) if tt.wantErr == "" && err != nil { t.Fatalf("UpdateNotifyChannel: %v", err) } if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { t.Fatalf("err = %v, want contains %q", err, tt.wantErr) } }) } } // TestNotifyChannelSecretLifecycle 验证密文字段的存取语义: // 保存后视图只回 set/tail,nil 沿用旧值,空串清除。 func TestNotifyChannelSecretLifecycle(t *testing.T) { settings, _ := newSettingEnv(t) ctx := context.Background() secret := "ntfy-token-9876" mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t", Secret: &secret}) view := mustChannelView(t, settings, ChannelNtfy) if !view.SecretSet || view.SecretTail != "9876" { t.Fatalf("view = %+v, want SecretSet 且尾号 9876", view) } cfg, err := settings.ntfyChannel(ctx) if err != nil || cfg.Token != secret { t.Fatalf("ntfyChannel token = %q err = %v, want 原文", cfg.Token, err) } // nil 沿用 mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2"}) if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != secret || cfg.Topic != "t2" { t.Fatalf("nil Secret 应沿用旧 token,got %+v", cfg) } // 空串清除 empty := "" mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2", Secret: &empty}) if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != "" { t.Fatalf("空串 Secret 应清除,got %q", cfg.Token) } if view := mustChannelView(t, settings, ChannelNtfy); view.SecretSet { t.Fatal("清除后 SecretSet 应为 false") } } // mustChannelView 取单渠道脱敏视图。 func mustChannelView(t *testing.T, s *SettingService, chType string) NotifyChannelView { t.Helper() views, err := s.NotifyChannels(context.Background()) if err != nil { t.Fatalf("NotifyChannels: %v", err) } for _, v := range views { if v.Type == chType { return v } } t.Fatalf("view %s not found", chType) return NotifyChannelView{} }