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", ) } }