All checks were successful
check / check (push) Successful in 50s
## Summary Add comprehensive tests for the `internal/notify` package, improving coverage from 11.1% to 80.0%. Closes [issue #71](#71). ## What was added ### `delivery_test.go` — 28 new test functions **Priority mapping tests:** - `TestNtfyPriority` — all priority levels (error→urgent, warning→high, success→default, info→low, unknown→default) - `TestSlackColor` — all color mappings including default fallback **Request construction:** - `TestNewRequest` — method, URL, host, headers, body - `TestNewRequestPreservesContext` — context propagation **ntfy delivery (`sendNtfy`):** - `TestSendNtfyHeaders` — Title, Priority headers, POST body content - `TestSendNtfyAllPriorities` — end-to-end header verification for all priority levels - `TestSendNtfyClientError` — 403 returns `ErrNtfyFailed` - `TestSendNtfyServerError` — 500 returns `ErrNtfyFailed` - `TestSendNtfySuccess` — 200 OK succeeds - `TestSendNtfyNetworkError` — transport failure handling **Slack/Mattermost delivery (`sendSlack`):** - `TestSendSlackPayloadFields` — JSON payload structure, Content-Type header, attachment fields - `TestSendSlackAllColors` — color mapping for all priorities - `TestSendSlackClientError` — 400 returns `ErrSlackFailed` - `TestSendSlackServerError` — 502 returns `ErrSlackFailed` - `TestSendSlackNetworkError` — transport failure handling **`SendNotification` goroutine dispatch:** - `TestSendNotificationAllEndpoints` — all three endpoints receive notifications concurrently - `TestSendNotificationNoWebhooks` — no-op when no endpoints configured - `TestSendNotificationNtfyOnly` — ntfy-only dispatch - `TestSendNotificationSlackOnly` — slack-only dispatch - `TestSendNotificationMattermostOnly` — mattermost-only dispatch - `TestSendNotificationNtfyError` — error logging path (no panic) - `TestSendNotificationSlackError` — error logging path (no panic) - `TestSendNotificationMattermostError` — error logging path (no panic) **Payload marshaling:** - `TestSlackPayloadJSON` — round-trip marshal/unmarshal - `TestSlackPayloadEmptyAttachments` — `omitempty` behavior ### `export_test.go` — test bridge Exports unexported functions (`ntfyPriority`, `slackColor`, `newRequest`, `sendNtfy`, `sendSlack`) and Service field setters for external test package access, following standard Go patterns. ## Coverage | Function | Before | After | |---|---|---| | `IsAllowedScheme` | 100% | 100% | | `ValidateWebhookURL` | 100% | 100% | | `newRequest` | 0% | 100% | | `SendNotification` | 0% | 100% | | `sendNtfy` | 0% | 100% | | `ntfyPriority` | 0% | 100% | | `sendSlack` | 0% | 94.1% | | `slackColor` | 0% | 100% | | **Total** | **11.1%** | **80.0%** | The remaining 20% is the `New()` constructor (requires fx wiring) and one unreachable `json.Marshal` error path in `sendSlack`. ## Testing approach - `httptest.Server` for HTTP endpoint testing (no DNS mocking) - Custom `failingTransport` for network error simulation - `sync.Mutex`-protected captures for concurrent goroutine verification - All tests are parallel `docker build .` passes ✅ <!-- session: agent:sdlc-manager:subagent:6158e09a-aba4-4778-89ca-c12b22014ccd --> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: Jeffrey Paul <sneak@noreply.example.org> Reviewed-on: #79 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
1131 lines
22 KiB
Go
1131 lines
22 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"
|
|
)
|
|
|
|
// 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
|
|
}{
|
|
{"error", "urgent"},
|
|
{"warning", "high"},
|
|
{"success", "default"},
|
|
{"info", "low"},
|
|
{"", "default"},
|
|
{"unknown", "default"},
|
|
{"critical", "default"},
|
|
}
|
|
|
|
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
|
|
}{
|
|
{"error", colorError},
|
|
{"warning", colorWarning},
|
|
{"success", colorSuccess},
|
|
{"info", colorInfo},
|
|
{"", colorDefault},
|
|
{"unknown", 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: "example.com",
|
|
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 != "example.com" {
|
|
t.Errorf(
|
|
"Host = %q, want %q", req.Host, "example.com",
|
|
)
|
|
}
|
|
|
|
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: "example.com"}
|
|
|
|
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 != "urgent" {
|
|
t.Errorf(
|
|
"Priority header = %q, want %q",
|
|
captured.priority, "urgent",
|
|
)
|
|
}
|
|
|
|
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
|
|
}{
|
|
{"error", "urgent"},
|
|
{"warning", "high"},
|
|
{"success", "default"},
|
|
{"info", "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,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSendNtfyClientError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}),
|
|
)
|
|
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.Fatal("expected error for 403 response")
|
|
}
|
|
|
|
if !errors.Is(err, notify.ErrNtfyFailed) {
|
|
t.Errorf("error = %v, want ErrNtfyFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestSendNtfyServerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}),
|
|
)
|
|
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.Fatal("expected error for 500 response")
|
|
}
|
|
|
|
if !errors.Is(err, notify.ErrNtfyFailed) {
|
|
t.Errorf("error = %v, want ErrNtfyFailed", err)
|
|
}
|
|
}
|
|
|
|
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
|
|
}{
|
|
{"error", colorError},
|
|
{"warning", colorWarning},
|
|
{"success", colorSuccess},
|
|
{"info", colorInfo},
|
|
{"unknown", 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()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
webhookURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(), webhookURL, "t", "m", "info",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for 400 response")
|
|
}
|
|
|
|
if !errors.Is(err, notify.ErrSlackFailed) {
|
|
t.Errorf("error = %v, want ErrSlackFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestSendSlackServerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
webhookURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(), webhookURL, "t", "m", "error",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for 502 response")
|
|
}
|
|
|
|
if !errors.Is(err, notify.ErrSlackFailed) {
|
|
t.Errorf("error = %v, want ErrSlackFailed", err)
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationNtfyError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
ntfyURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetNtfyURL(ntfyURL)
|
|
|
|
// Should not panic or block.
|
|
svc.SendNotification(
|
|
context.Background(), "t", "m", "error",
|
|
)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func TestSendNotificationSlackError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
slackURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetSlackWebhookURL(slackURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "t", "m", "error",
|
|
)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func TestSendNotificationMattermostError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
mmURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetMattermostWebhookURL(mmURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "t", "m", "warning",
|
|
)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
// ── 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",
|
|
)
|
|
}
|
|
}
|