All checks were successful
check / check (push) Successful in 33s
Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5 (v2.12.2) in Dockerfile and script/bootstrap. Set .golangci.yml to the org-standard v2-schema config already used across the org's repos (owner-authorized; the same file is being landed as canonical via a prompts-repo PR). Lint settings live under linters.settings, so the lll, funlen, cyclop, and dupl thresholds are actually applied. The informational gomodguard deprecation warning this config can emit under v2.12 is accepted. 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",
|
|
)
|
|
}
|
|
}
|