Files
dnswatcher/internal/notify/delivery_test.go
sneak cd34e52064
All checks were successful
check / check (push) Successful in 31s
build: update golangci-lint to v2.12.2 with new canonical v2 config
Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5
(v2.12.2) in Dockerfile and script/bootstrap.

Replace .golangci.yml with a v2-schema migration of the canonical
config, produced with golangci-lint migrate (owner-authorized; the
same file becomes the new org-wide canonical via a prompts-repo PR).
Lint settings now live under linters.settings, so the lll, funlen,
cyclop, and dupl thresholds are actually applied. The deprecated
gomodguard linter is disabled in favor of gomodguard_v2, resolving
the v2.12 deprecation warning. Deliberate delta from the migrate
output: the gci formatter is not enabled because its default two-group
import ordering conflicts with the repo's stdlib/third-party/local
import style that script/fmt (gofmt + goimports) produces.

Fix all findings surfaced by the now-active thresholds:

- goconst: shared constants for repeated status, priority, and DNS
  fixture strings in watcher.go and the notify, state, and watcher
  tests
- dupl: consolidate duplicated ntfy/slack HTTP-error tests and
  SendNotification endpoint-error tests behind shared helpers
- lll: wrap long test table entries and comments; shorten one inline
  nolint justification
2026-08-07 20:38:01 +00:00

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