All checks were successful
check / check (push) Successful in 31s
Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5 (v2.12.2) in Dockerfile and script/bootstrap. Replace .golangci.yml with a v2-schema migration of the canonical config, produced with golangci-lint migrate (owner-authorized; the same file becomes the new org-wide canonical via a prompts-repo PR). Lint settings now live under linters.settings, so the lll, funlen, cyclop, and dupl thresholds are actually applied. The deprecated gomodguard linter is disabled in favor of gomodguard_v2, resolving the v2.12 deprecation warning. Deliberate delta from the migrate output: the gci formatter is not enabled because its default two-group import ordering conflicts with the repo's stdlib/third-party/local import style that script/fmt (gofmt + goimports) produces. Fix all findings surfaced by the now-active thresholds: - goconst: shared constants for repeated status, priority, and DNS fixture strings in watcher.go and the notify, state, and watcher tests - dupl: consolidate duplicated ntfy/slack HTTP-error tests and SendNotification endpoint-error tests behind shared helpers - lll: wrap long test table entries and comments; shorten one inline nolint justification
1112 lines
21 KiB
Go
1112 lines
21 KiB
Go
package notify_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
|
)
|
|
|
|
// Color constants used across multiple tests.
|
|
const (
|
|
colorError = "#dc3545"
|
|
colorWarning = "#ffc107"
|
|
colorSuccess = "#28a745"
|
|
colorInfo = "#17a2b8"
|
|
colorDefault = "#6c757d"
|
|
)
|
|
|
|
// Priority strings used across multiple tests.
|
|
const (
|
|
prioError = "error"
|
|
prioWarning = "warning"
|
|
prioSuccess = "success"
|
|
prioInfo = "info"
|
|
prioUnknown = "unknown"
|
|
prioDefault = "default"
|
|
prioUrgent = "urgent"
|
|
)
|
|
|
|
// testHost is the hostname used in request construction tests.
|
|
const testHost = "example.com"
|
|
|
|
// errSimulated is a static error for transport failures.
|
|
var errSimulated = errors.New("simulated transport failure")
|
|
|
|
// failingTransport always returns an error on RoundTrip.
|
|
type failingTransport struct {
|
|
err error
|
|
}
|
|
|
|
func (ft *failingTransport) RoundTrip(
|
|
_ *http.Request,
|
|
) (*http.Response, error) {
|
|
return nil, ft.err
|
|
}
|
|
|
|
// waitForCondition polls until fn returns true or the test
|
|
// times out. This accommodates the goroutine-based dispatch
|
|
// in SendNotification.
|
|
func waitForCondition(t *testing.T, fn func() bool) {
|
|
t.Helper()
|
|
|
|
const (
|
|
maxAttempts = 200
|
|
pollDelay = 10 * time.Millisecond
|
|
)
|
|
|
|
for range maxAttempts {
|
|
if fn() {
|
|
return
|
|
}
|
|
|
|
time.Sleep(pollDelay)
|
|
}
|
|
|
|
t.Fatal("condition not met within timeout")
|
|
}
|
|
|
|
// slackCapture holds values captured from a Slack/Mattermost
|
|
// webhook request, protected by a mutex for goroutine safety.
|
|
type slackCapture struct {
|
|
mu sync.Mutex
|
|
called bool
|
|
payload notify.SlackPayload
|
|
}
|
|
|
|
func newSlackCaptureServer() (
|
|
*httptest.Server, *slackCapture,
|
|
) {
|
|
c := &slackCapture{}
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.called = true
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &c.payload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
return srv, c
|
|
}
|
|
|
|
// ── ntfyPriority ──────────────────────────────────────────
|
|
|
|
func TestNtfyPriority(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{prioError, prioUrgent},
|
|
{prioWarning, "high"},
|
|
{prioSuccess, prioDefault},
|
|
{prioInfo, "low"},
|
|
{"", prioDefault},
|
|
{prioUnknown, prioDefault},
|
|
{"critical", prioDefault},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := notify.NtfyPriority(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf(
|
|
"NtfyPriority(%q) = %q, want %q",
|
|
tc.input, got, tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── slackColor ────────────────────────────────────────────
|
|
|
|
func TestSlackColor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{prioError, colorError},
|
|
{prioWarning, colorWarning},
|
|
{prioSuccess, colorSuccess},
|
|
{prioInfo, colorInfo},
|
|
{"", colorDefault},
|
|
{prioUnknown, colorDefault},
|
|
{"critical", colorDefault},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := notify.SlackColor(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf(
|
|
"SlackColor(%q) = %q, want %q",
|
|
tc.input, got, tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── newRequest ────────────────────────────────────────────
|
|
|
|
func TestNewRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := &url.URL{
|
|
Scheme: "https",
|
|
Host: testHost,
|
|
Path: "/webhook",
|
|
}
|
|
body := bytes.NewBufferString("hello")
|
|
ctx := context.Background()
|
|
|
|
req := notify.NewRequestForTest(
|
|
ctx, http.MethodPost, target, body,
|
|
)
|
|
|
|
if req.Method != http.MethodPost {
|
|
t.Errorf("Method = %q, want POST", req.Method)
|
|
}
|
|
|
|
if req.URL.String() != "https://example.com/webhook" {
|
|
t.Errorf(
|
|
"URL = %q, want %q",
|
|
req.URL.String(),
|
|
"https://example.com/webhook",
|
|
)
|
|
}
|
|
|
|
if req.Host != testHost {
|
|
t.Errorf(
|
|
"Host = %q, want %q", req.Host, testHost,
|
|
)
|
|
}
|
|
|
|
if req.Header == nil {
|
|
t.Error("Header map should be initialized")
|
|
}
|
|
|
|
got, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
t.Fatalf("reading body: %v", err)
|
|
}
|
|
|
|
if string(got) != "hello" {
|
|
t.Errorf("Body = %q, want %q", string(got), "hello")
|
|
}
|
|
}
|
|
|
|
func TestNewRequestPreservesContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type ctxKey string
|
|
|
|
ctx := context.WithValue(
|
|
context.Background(),
|
|
ctxKey("k"),
|
|
"v",
|
|
)
|
|
target := &url.URL{Scheme: "https", Host: testHost}
|
|
|
|
req := notify.NewRequestForTest(
|
|
ctx, http.MethodGet, target, http.NoBody,
|
|
)
|
|
|
|
if req.Context().Value(ctxKey("k")) != "v" {
|
|
t.Error("context value not preserved")
|
|
}
|
|
}
|
|
|
|
// ── sendNtfy ──────────────────────────────────────────────
|
|
|
|
// ntfyCapture holds values captured from an ntfy request.
|
|
type ntfyCapture struct {
|
|
method string
|
|
title string
|
|
priority string
|
|
body string
|
|
}
|
|
|
|
func newNtfyCaptureServer() (*httptest.Server, *ntfyCapture) {
|
|
c := &ntfyCapture{}
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
c.method = r.Method
|
|
c.title = r.Header.Get("Title")
|
|
c.priority = r.Header.Get("Priority")
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
c.body = string(b)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
return srv, c
|
|
}
|
|
|
|
func TestSendNtfyHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv, captured := newNtfyCaptureServer()
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
topicURL, _ := url.Parse(srv.URL + "/test-topic")
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(),
|
|
topicURL,
|
|
"Test Title",
|
|
"Test message body",
|
|
"error",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendNtfy returned error: %v", err)
|
|
}
|
|
|
|
if captured.method != http.MethodPost {
|
|
t.Errorf("method = %q, want POST", captured.method)
|
|
}
|
|
|
|
if captured.title != "Test Title" {
|
|
t.Errorf(
|
|
"Title header = %q, want %q",
|
|
captured.title, "Test Title",
|
|
)
|
|
}
|
|
|
|
if captured.priority != prioUrgent {
|
|
t.Errorf(
|
|
"Priority header = %q, want %q",
|
|
captured.priority, prioUrgent,
|
|
)
|
|
}
|
|
|
|
if captured.body != "Test message body" {
|
|
t.Errorf(
|
|
"body = %q, want %q",
|
|
captured.body, "Test message body",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNtfyAllPriorities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
priorities := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{prioError, prioUrgent},
|
|
{prioWarning, "high"},
|
|
{prioSuccess, prioDefault},
|
|
{prioInfo, "low"},
|
|
}
|
|
|
|
for _, tc := range priorities {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var gotPriority string
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
gotPriority = r.Header.Get("Priority")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(
|
|
srv.Client().Transport,
|
|
)
|
|
topicURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(),
|
|
topicURL, "t", "m", tc.input,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendNtfy error: %v", err)
|
|
}
|
|
|
|
if gotPriority != tc.want {
|
|
t.Errorf(
|
|
"priority %q: got %q, want %q",
|
|
tc.input, gotPriority, tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertSendStatusError verifies that send returns an error
|
|
// wrapping wantErr when the server responds with status.
|
|
func assertSendStatusError(
|
|
t *testing.T,
|
|
status int,
|
|
wantErr error,
|
|
send func(*notify.Service, *url.URL) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(status)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
target, _ := url.Parse(srv.URL)
|
|
|
|
err := send(svc, target)
|
|
if err == nil {
|
|
t.Fatalf("expected error for %d response", status)
|
|
}
|
|
|
|
if !errors.Is(err, wantErr) {
|
|
t.Errorf("error = %v, want %v", err, wantErr)
|
|
}
|
|
}
|
|
|
|
func sendNtfyInfo(
|
|
svc *notify.Service, target *url.URL,
|
|
) error {
|
|
return svc.SendNtfy(
|
|
context.Background(), target, "t", "m", prioInfo,
|
|
)
|
|
}
|
|
|
|
func sendSlackInfo(
|
|
svc *notify.Service, target *url.URL,
|
|
) error {
|
|
return svc.SendSlack(
|
|
context.Background(), target, "t", "m", prioInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendNtfyClientError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusForbidden,
|
|
notify.ErrNtfyFailed, sendNtfyInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendNtfyServerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusInternalServerError,
|
|
notify.ErrNtfyFailed, sendNtfyInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendNtfySuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
topicURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(), topicURL, "t", "m", "info",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected success for 200: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendNtfyNetworkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := &failingTransport{err: errSimulated}
|
|
|
|
svc := notify.NewTestService(transport)
|
|
topicURL, _ := url.Parse(
|
|
"http://unreachable.invalid/topic",
|
|
)
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(), topicURL, "t", "m", "info",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for network failure")
|
|
}
|
|
}
|
|
|
|
// ── sendSlack ─────────────────────────────────────────────
|
|
|
|
func TestSendSlackPayloadFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
gotContentType string
|
|
gotPayload notify.SlackPayload
|
|
gotMethod string
|
|
)
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &gotPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
webhookURL, _ := url.Parse(srv.URL + "/hooks/test")
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(),
|
|
webhookURL,
|
|
"Alert Title",
|
|
"Alert body text",
|
|
"warning",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendSlack returned error: %v", err)
|
|
}
|
|
|
|
assertSlackPayload(
|
|
t,
|
|
gotMethod,
|
|
gotContentType,
|
|
gotPayload,
|
|
"Alert Title",
|
|
"Alert body text",
|
|
colorWarning,
|
|
)
|
|
}
|
|
|
|
func assertSlackPayload(
|
|
t *testing.T,
|
|
method, contentType string,
|
|
payload notify.SlackPayload,
|
|
wantTitle, wantText, wantColor string,
|
|
) {
|
|
t.Helper()
|
|
|
|
if method != http.MethodPost {
|
|
t.Errorf("method = %q, want POST", method)
|
|
}
|
|
|
|
if contentType != "application/json" {
|
|
t.Errorf(
|
|
"Content-Type = %q, want application/json",
|
|
contentType,
|
|
)
|
|
}
|
|
|
|
if len(payload.Attachments) != 1 {
|
|
t.Fatalf(
|
|
"attachments length = %d, want 1",
|
|
len(payload.Attachments),
|
|
)
|
|
}
|
|
|
|
att := payload.Attachments[0]
|
|
|
|
if att.Title != wantTitle {
|
|
t.Errorf(
|
|
"title = %q, want %q", att.Title, wantTitle,
|
|
)
|
|
}
|
|
|
|
if att.Text != wantText {
|
|
t.Errorf("text = %q, want %q", att.Text, wantText)
|
|
}
|
|
|
|
if att.Color != wantColor {
|
|
t.Errorf(
|
|
"color = %q, want %q", att.Color, wantColor,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendSlackAllColors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
colors := []struct {
|
|
priority string
|
|
want string
|
|
}{
|
|
{prioError, colorError},
|
|
{prioWarning, colorWarning},
|
|
{prioSuccess, colorSuccess},
|
|
{prioInfo, colorInfo},
|
|
{prioUnknown, colorDefault},
|
|
}
|
|
|
|
for _, tc := range colors {
|
|
t.Run(tc.priority, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var gotPayload notify.SlackPayload
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &gotPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(
|
|
srv.Client().Transport,
|
|
)
|
|
webhookURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(),
|
|
webhookURL, "t", "m", tc.priority,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendSlack error: %v", err)
|
|
}
|
|
|
|
if len(gotPayload.Attachments) == 0 {
|
|
t.Fatal("no attachments in payload")
|
|
}
|
|
|
|
if gotPayload.Attachments[0].Color != tc.want {
|
|
t.Errorf(
|
|
"priority %q: color = %q, want %q",
|
|
tc.priority,
|
|
gotPayload.Attachments[0].Color,
|
|
tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSendSlackClientError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusBadRequest,
|
|
notify.ErrSlackFailed, sendSlackInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendSlackServerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusBadGateway,
|
|
notify.ErrSlackFailed, sendSlackInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendSlackNetworkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := &failingTransport{err: errSimulated}
|
|
|
|
svc := notify.NewTestService(transport)
|
|
webhookURL, _ := url.Parse(
|
|
"http://unreachable.invalid/hooks",
|
|
)
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(), webhookURL, "t", "m", "info",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for network failure")
|
|
}
|
|
}
|
|
|
|
// ── SendNotification (exported) ───────────────────────────
|
|
|
|
// endpointResult captures concurrent results from all three
|
|
// notification endpoints.
|
|
type endpointResult struct {
|
|
mu sync.Mutex
|
|
ntfyCalled bool
|
|
ntfyTitle string
|
|
slackCalled bool
|
|
slackPayload notify.SlackPayload
|
|
mmCalled bool
|
|
mmPayload notify.SlackPayload
|
|
}
|
|
|
|
func newEndpointServers(
|
|
r *endpointResult,
|
|
) (*httptest.Server, *httptest.Server, *httptest.Server) {
|
|
ntfy := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.ntfyCalled = true
|
|
r.ntfyTitle = req.Header.Get("Title")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
slack := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.slackCalled = true
|
|
|
|
b, _ := io.ReadAll(req.Body)
|
|
_ = json.Unmarshal(b, &r.slackPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
mm := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.mmCalled = true
|
|
|
|
b, _ := io.ReadAll(req.Body)
|
|
_ = json.Unmarshal(b, &r.mmPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
return ntfy, slack, mm
|
|
}
|
|
|
|
func assertAllEndpointsResult(
|
|
t *testing.T, r *endpointResult,
|
|
) {
|
|
t.Helper()
|
|
|
|
if r.ntfyTitle != "DNS Changed" {
|
|
t.Errorf(
|
|
"ntfy Title = %q, want %q",
|
|
r.ntfyTitle, "DNS Changed",
|
|
)
|
|
}
|
|
|
|
if len(r.slackPayload.Attachments) == 0 {
|
|
t.Fatal("slack payload has no attachments")
|
|
}
|
|
|
|
if r.slackPayload.Attachments[0].Title != "DNS Changed" {
|
|
t.Errorf(
|
|
"slack title = %q, want %q",
|
|
r.slackPayload.Attachments[0].Title,
|
|
"DNS Changed",
|
|
)
|
|
}
|
|
|
|
if len(r.mmPayload.Attachments) == 0 {
|
|
t.Fatal("mattermost payload has no attachments")
|
|
}
|
|
|
|
wantText := "example.com A record updated"
|
|
if r.mmPayload.Attachments[0].Text != wantText {
|
|
t.Errorf(
|
|
"mattermost text = %q, want %q",
|
|
r.mmPayload.Attachments[0].Text,
|
|
wantText,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationAllEndpoints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := &endpointResult{}
|
|
ntfySrv, slackSrv, mmSrv := newEndpointServers(result)
|
|
|
|
defer ntfySrv.Close()
|
|
defer slackSrv.Close()
|
|
defer mmSrv.Close()
|
|
|
|
ntfyURL, _ := url.Parse(ntfySrv.URL)
|
|
slackURL, _ := url.Parse(slackSrv.URL)
|
|
mmURL, _ := url.Parse(mmSrv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetNtfyURL(ntfyURL)
|
|
svc.SetSlackWebhookURL(slackURL)
|
|
svc.SetMattermostWebhookURL(mmURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(),
|
|
"DNS Changed",
|
|
"example.com A record updated",
|
|
"warning",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
result.mu.Lock()
|
|
defer result.mu.Unlock()
|
|
|
|
return result.ntfyCalled &&
|
|
result.slackCalled &&
|
|
result.mmCalled
|
|
})
|
|
|
|
result.mu.Lock()
|
|
defer result.mu.Unlock()
|
|
|
|
assertAllEndpointsResult(t, result)
|
|
}
|
|
|
|
func TestSendNotificationNoWebhooks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
|
|
// All URL fields are nil — this should be a no-op.
|
|
svc.SendNotification(
|
|
context.Background(), "Title", "Message", "info",
|
|
)
|
|
}
|
|
|
|
func TestSendNotificationNtfyOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
called bool
|
|
gotTitle string
|
|
)
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
called = true
|
|
gotTitle = r.Header.Get("Title")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
ntfyURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetNtfyURL(ntfyURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "Only Ntfy", "body", "info",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return called
|
|
})
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if gotTitle != "Only Ntfy" {
|
|
t.Errorf(
|
|
"title = %q, want %q", gotTitle, "Only Ntfy",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationSlackOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
called bool
|
|
payload notify.SlackPayload
|
|
)
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
called = true
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &payload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
slackURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetSlackWebhookURL(slackURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(),
|
|
"Slack Only", "body", "error",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return called
|
|
})
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if len(payload.Attachments) == 0 {
|
|
t.Fatal("no attachments")
|
|
}
|
|
|
|
if payload.Attachments[0].Color != colorError {
|
|
t.Errorf(
|
|
"color = %q, want %q",
|
|
payload.Attachments[0].Color, colorError,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationMattermostOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv, capture := newSlackCaptureServer()
|
|
defer srv.Close()
|
|
|
|
mmURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetMattermostWebhookURL(mmURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(),
|
|
"MM Only", "body", "success",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
capture.mu.Lock()
|
|
defer capture.mu.Unlock()
|
|
|
|
return capture.called
|
|
})
|
|
|
|
capture.mu.Lock()
|
|
defer capture.mu.Unlock()
|
|
|
|
if len(capture.payload.Attachments) == 0 {
|
|
t.Fatal("no attachments")
|
|
}
|
|
|
|
att := capture.payload.Attachments[0]
|
|
|
|
if att.Title != "MM Only" {
|
|
t.Errorf(
|
|
"title = %q, want %q", att.Title, "MM Only",
|
|
)
|
|
}
|
|
|
|
if att.Color != colorSuccess {
|
|
t.Errorf(
|
|
"color = %q, want %q", att.Color, colorSuccess,
|
|
)
|
|
}
|
|
}
|
|
|
|
// assertSendNotificationTolerates verifies SendNotification
|
|
// neither panics nor blocks when the endpoint configured by
|
|
// setURL responds with status.
|
|
func assertSendNotificationTolerates(
|
|
t *testing.T,
|
|
status int,
|
|
priority string,
|
|
setURL func(*notify.Service, *url.URL),
|
|
) {
|
|
t.Helper()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(status)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
target, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
setURL(svc, target)
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "t", "m", priority,
|
|
)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func TestSendNotificationNtfyError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendNotificationTolerates(
|
|
t, http.StatusInternalServerError, prioError,
|
|
(*notify.Service).SetNtfyURL,
|
|
)
|
|
}
|
|
|
|
func TestSendNotificationSlackError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendNotificationTolerates(
|
|
t, http.StatusForbidden, prioError,
|
|
(*notify.Service).SetSlackWebhookURL,
|
|
)
|
|
}
|
|
|
|
func TestSendNotificationMattermostError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendNotificationTolerates(
|
|
t, http.StatusBadGateway, prioWarning,
|
|
(*notify.Service).SetMattermostWebhookURL,
|
|
)
|
|
}
|
|
|
|
// ── SlackPayload JSON marshaling ──────────────────────────
|
|
|
|
func TestSlackPayloadJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := notify.SlackPayload{
|
|
Text: "fallback",
|
|
Attachments: []notify.SlackAttachment{
|
|
{
|
|
Color: colorSuccess,
|
|
Title: "Test",
|
|
Text: "body",
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
var decoded notify.SlackPayload
|
|
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if decoded.Text != "fallback" {
|
|
t.Errorf(
|
|
"Text = %q, want %q", decoded.Text, "fallback",
|
|
)
|
|
}
|
|
|
|
if len(decoded.Attachments) != 1 {
|
|
t.Fatalf(
|
|
"Attachments len = %d, want 1",
|
|
len(decoded.Attachments),
|
|
)
|
|
}
|
|
|
|
att := decoded.Attachments[0]
|
|
|
|
if att.Color != colorSuccess {
|
|
t.Errorf(
|
|
"Color = %q, want %q", att.Color, colorSuccess,
|
|
)
|
|
}
|
|
|
|
if att.Title != "Test" {
|
|
t.Errorf("Title = %q, want %q", att.Title, "Test")
|
|
}
|
|
|
|
if att.Text != "body" {
|
|
t.Errorf("Text = %q, want %q", att.Text, "body")
|
|
}
|
|
}
|
|
|
|
func TestSlackPayloadEmptyAttachments(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := notify.SlackPayload{Text: "no attachments"}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
var raw map[string]json.RawMessage
|
|
|
|
err = json.Unmarshal(data, &raw)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal raw: %v", err)
|
|
}
|
|
|
|
if _, exists := raw["attachments"]; exists {
|
|
t.Error(
|
|
"attachments should be omitted when empty",
|
|
)
|
|
}
|
|
}
|