Files
dnswatcher/internal/notify/delivery_test.go
user e8cd0705a7
All checks were successful
check / check (push) Successful in 37s
test(notify): add comprehensive tests for notification delivery
Add tests for sendNtfy, sendSlack, SendNotification, newRequest,
ntfyPriority, and slackColor covering:

- Correct ntfy headers (Title, Priority) and body content
- All priority-to-ntfy-priority mappings
- Correct Slack/Mattermost JSON payload structure and Content-Type
- All priority-to-color mappings
- HTTP error handling (4xx, 5xx status codes)
- Network transport failures
- Goroutine dispatch to all configured endpoints
- Individual endpoint dispatch (ntfy-only, slack-only, mattermost-only)
- No-op behavior when no webhooks are configured
- Error logging paths in SendNotification goroutines
- SlackPayload JSON marshaling and omitempty behavior
- HTTP request construction via newRequest
- Context propagation through newRequest

Uses httptest.Server for HTTP endpoint testing and a custom
failingTransport for network error simulation. Test helpers are
exported via export_test.go following standard Go patterns.

Coverage improved from 11.1% to 80.0% for the notify package.
2026-03-01 23:52:12 -08:00

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