package main import ( "encoding/json" "fmt" "math" "net/http" "reflect" "strings" "testing" "unicode/utf8" ) const v2TestTimestamp = "2026-07-16T12:34:56Z" func TestV2NotificationPayloads(t *testing.T) { tests := []struct { name string path string body string want discordPayload }{ { name: "generic message", path: "/api/v2/messages", body: `{"title":"Maintenance","message":"The storage job completed.","source":"scheduler","occurredAt":"2026-07-16T09:34:56-03:00","extra":[{"name":"Region","value":"home"},{"name":"Details","value":"first line\nsecond line"}]}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "💬 Maintenance", Description: "The storage job completed.", Color: v2ColorBrand, Timestamp: v2TestTimestamp, Fields: []discordEmbedField{ {Name: "Source", Value: "scheduler", Inline: true}, {Name: "Region", Value: "home", Inline: true}, {Name: "Details", Value: "first line\nsecond line", Inline: false}, }, Footer: &discordEmbedFooter{Text: "Haven Notify"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, { name: "backup", path: "/api/v2/notifications/backup", body: `{"title":"Nightly backup","asset":"Photos","sizeBytes":1610612736,"durationSeconds":125,"source":"restic","extra":[{"name":"Host","value":"nebula"}]}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "📦 Nightly backup", Description: "**Photos** was backed up successfully.", Color: v2ColorSuccess, Timestamp: v2TestTimestamp, Fields: []discordEmbedField{ {Name: "Size", Value: "1.50 GiB", Inline: true}, {Name: "Duration", Value: "2m 5s", Inline: true}, {Name: "Source", Value: "restic", Inline: true}, {Name: "Host", Value: "nebula", Inline: true}, }, Footer: &discordEmbedFooter{Text: "Haven Notify"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, { name: "update", path: "/api/v2/notifications/update", body: `{"host":"nexus","asset":"k3s","durationSeconds":42.5,"fromVersion":"1.32.5","toVersion":"1.33.1","source":"system-updater"}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "🔄 Update completed", Description: "**k3s** was updated on **nexus**.", Color: v2ColorUpdate, Timestamp: v2TestTimestamp, Fields: []discordEmbedField{ {Name: "Version", Value: "1.32.5 → 1.33.1", Inline: true}, {Name: "Duration", Value: "42.5s", Inline: true}, {Name: "Source", Value: "system-updater", Inline: true}, }, Footer: &discordEmbedFooter{Text: "Haven Notify"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, { name: "critical error", path: "/api/v2/notifications/error", body: `{"source":"backup-job","message":"The repository is unavailable.","severity":"critical","errorCode":"E_REPOSITORY","extra":[{"name":"Path","value":"/mnt/backups"}]}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "🛑 Critical error", Description: "The repository is unavailable.", Color: v2ColorCritical, Timestamp: v2TestTimestamp, Fields: []discordEmbedField{ {Name: "Source", Value: "backup-job", Inline: true}, {Name: "Error code", Value: "E_REPOSITORY", Inline: true}, {Name: "Path", Value: "/mnt/backups", Inline: true}, }, Footer: &discordEmbedFooter{Text: "Haven Notify"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := sendV2Request(t, test.path, test.body) if !reflect.DeepEqual(got, test.want) { gotJSON, _ := json.MarshalIndent(got, "", " ") wantJSON, _ := json.MarshalIndent(test.want, "", " ") t.Fatalf("payload = %s\nwant = %s", gotJSON, wantJSON) } }) } } func TestV2DefaultsAndVariants(t *testing.T) { tests := []struct { name string path string body string wantTitle string wantColor int wantFields []discordEmbedField }{ { name: "message default title", path: "/api/v2/messages", body: `{"message":"hello"}`, wantTitle: "💬 Message", wantColor: v2ColorBrand, }, { name: "backup defaults", path: "/api/v2/notifications/backup", body: `{"asset":"config","sizeBytes":0}`, wantTitle: "📦 Backup completed", wantColor: v2ColorSuccess, wantFields: []discordEmbedField{ {Name: "Size", Value: "0 B", Inline: true}, }, }, { name: "update previous version", path: "/api/v2/notifications/update", body: `{"host":"nexus","asset":"k3s","durationSeconds":60,"fromVersion":"1.0"}`, wantTitle: "🔄 Update completed", wantColor: v2ColorUpdate, wantFields: []discordEmbedField{ {Name: "Previous version", Value: "1.0", Inline: true}, {Name: "Duration", Value: "1m", Inline: true}, }, }, { name: "update installed version", path: "/api/v2/notifications/update", body: `{"host":"nexus","asset":"k3s","durationSeconds":0,"toVersion":"1.1"}`, wantTitle: "🔄 Update completed", wantColor: v2ColorUpdate, wantFields: []discordEmbedField{ {Name: "Installed version", Value: "1.1", Inline: true}, {Name: "Duration", Value: "0s", Inline: true}, }, }, { name: "warning", path: "/api/v2/notifications/error", body: `{"source":"job","message":"slow","severity":"warning"}`, wantTitle: "⚠️ Warning", wantColor: v2ColorWarning, wantFields: []discordEmbedField{ {Name: "Source", Value: "job", Inline: true}, }, }, { name: "error", path: "/api/v2/notifications/error", body: `{"source":"job","message":"failed","severity":"error"}`, wantTitle: "❌ Error", wantColor: v2ColorError, wantFields: []discordEmbedField{ {Name: "Source", Value: "job", Inline: true}, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { payload := sendV2Request(t, test.path, test.body) embed := payload.Embeds[0] if embed.Title != test.wantTitle || embed.Color != test.wantColor || embed.Timestamp != v2TestTimestamp || !reflect.DeepEqual(embed.Fields, test.wantFields) { t.Fatalf("embed = %+v", embed) } }) } } func TestV2RequestValidation(t *testing.T) { tests := []struct { name string path string contentType string body string wantStatus int }{ {name: "message missing", path: "/api/v2/messages", body: `{}`, wantStatus: 400}, {name: "message blank", path: "/api/v2/messages", body: `{"message":" "}`, wantStatus: 400}, {name: "blank optional title", path: "/api/v2/messages", body: `{"message":"m","title":" "}`, wantStatus: 400}, {name: "blank optional source", path: "/api/v2/messages", body: `{"message":"m","source":" "}`, wantStatus: 400}, {name: "invalid timestamp", path: "/api/v2/messages", body: `{"message":"m","occurredAt":"yesterday"}`, wantStatus: 400}, {name: "blank timestamp", path: "/api/v2/messages", body: `{"message":"m","occurredAt":" "}`, wantStatus: 400}, {name: "null extras", path: "/api/v2/messages", body: `{"message":"m","extra":null}`, wantStatus: 400}, {name: "invalid extra", path: "/api/v2/messages", body: `{"message":"m","extra":[{"name":"n"}]}`, wantStatus: 400}, {name: "duplicate common key", path: "/api/v2/messages", body: `{"message":"m","source":"one","SOURCE":"two"}`, wantStatus: 400}, {name: "duplicate nested key", path: "/api/v2/messages", body: `{"message":"m","extra":[{"name":"one","NAME":"two","value":"v"}]}`, wantStatus: 400}, {name: "backup missing asset", path: "/api/v2/notifications/backup", body: `{"sizeBytes":1}`, wantStatus: 400}, {name: "backup missing size", path: "/api/v2/notifications/backup", body: `{"asset":"a"}`, wantStatus: 400}, {name: "backup negative size", path: "/api/v2/notifications/backup", body: `{"asset":"a","sizeBytes":-1}`, wantStatus: 400}, {name: "backup fractional size", path: "/api/v2/notifications/backup", body: `{"asset":"a","sizeBytes":1.5}`, wantStatus: 400}, {name: "backup negative duration", path: "/api/v2/notifications/backup", body: `{"asset":"a","sizeBytes":1,"durationSeconds":-1}`, wantStatus: 400}, {name: "update missing host", path: "/api/v2/notifications/update", body: `{"asset":"a","durationSeconds":1}`, wantStatus: 400}, {name: "update missing asset", path: "/api/v2/notifications/update", body: `{"host":"h","durationSeconds":1}`, wantStatus: 400}, {name: "update missing duration", path: "/api/v2/notifications/update", body: `{"host":"h","asset":"a"}`, wantStatus: 400}, {name: "update negative duration", path: "/api/v2/notifications/update", body: `{"host":"h","asset":"a","durationSeconds":-1}`, wantStatus: 400}, {name: "update blank version", path: "/api/v2/notifications/update", body: `{"host":"h","asset":"a","durationSeconds":1,"fromVersion":" "}`, wantStatus: 400}, {name: "error missing source", path: "/api/v2/notifications/error", body: `{"message":"m","severity":"error"}`, wantStatus: 400}, {name: "error missing message", path: "/api/v2/notifications/error", body: `{"source":"s","severity":"error"}`, wantStatus: 400}, {name: "error missing severity", path: "/api/v2/notifications/error", body: `{"source":"s","message":"m"}`, wantStatus: 400}, {name: "error invalid severity", path: "/api/v2/notifications/error", body: `{"source":"s","message":"m","severity":"ERROR"}`, wantStatus: 400}, {name: "error blank code", path: "/api/v2/notifications/error", body: `{"source":"s","message":"m","severity":"error","errorCode":" "}`, wantStatus: 400}, {name: "wrong media type", path: "/api/v2/messages", contentType: "text/plain", body: `{"message":"m"}`, wantStatus: 415}, {name: "unknown accepted", path: "/api/v2/messages", body: `{"message":"m","future":true}`, wantStatus: 200}, {name: "case insensitive accepted", path: "/api/v2/messages", body: `{"MESSAGE":"m","EXTRA":[{"NAME":"n","VALUE":"v"}]}`, wantStatus: 200}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { contentType := test.contentType if contentType == "" { contentType = "application/json" } recorder := &webhookRecorder{} response := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, test.path, contentType, test.body) if response.Code != test.wantStatus { t.Fatalf("response = (%d, %q), want %d", response.Code, response.Body.String(), test.wantStatus) } wantCalls := 0 if test.wantStatus == http.StatusOK { wantCalls = 1 } if calls := len(recorder.bodies()); calls != wantCalls { t.Fatalf("webhook calls = %d, want %d", calls, wantCalls) } }) } } func TestV2RoutingContracts(t *testing.T) { handler := testApplication(t, nil, nil).handler() for _, path := range []string{ "/api/v2/messages", "/api/v2/notifications/backup", "/api/v2/notifications/update", "/api/v2/notifications/error", } { response := performRequest(handler, http.MethodGet, path, "", "") if response.Code != http.StatusMethodNotAllowed || response.Header().Get("Allow") != http.MethodPost { t.Fatalf("GET %s = (%d, Allow %q)", path, response.Code, response.Header().Get("Allow")) } } response := performRequest(handler, http.MethodPost, "/api/v2/messages/", "application/json", `{"message":"m"}`) if response.Code != http.StatusNotFound { t.Fatalf("trailing slash status = %d", response.Code) } } func TestV2RequestBodyLimit(t *testing.T) { prefix, suffix := `{"message":"`, `"}` for _, test := range []struct { name string bodyBytes int want int wantCalls int }{ {name: "exactly at limit", bodyBytes: maxRequestBody, want: http.StatusOK, wantCalls: 1}, {name: "one byte over limit", bodyBytes: maxRequestBody + 1, want: http.StatusBadRequest}, } { t.Run(test.name, func(t *testing.T) { recorder := &webhookRecorder{} body := prefix + strings.Repeat("x", test.bodyBytes-len(prefix)-len(suffix)) + suffix response := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, "/api/v2/messages", "application/json", body) if response.Code != test.want { t.Fatalf("response status = %d, want %d", response.Code, test.want) } if calls := len(recorder.bodies()); calls != test.wantCalls { t.Fatalf("webhook calls = %d, want %d", calls, test.wantCalls) } }) } } func TestV2ExtrasRespectDiscordLimits(t *testing.T) { extra := make([]string, 30) for index := range extra { extra[index] = fmt.Sprintf(`{"name":"field-%02d","value":"value"}`, index) } body := fmt.Sprintf(`{"host":"nexus","asset":"k3s","durationSeconds":1,"fromVersion":"1","toVersion":"2","source":"updater","extra":[%s]}`, strings.Join(extra, ",")) payload := sendV2Request(t, "/api/v2/notifications/update", body) fields := payload.Embeds[0].Fields if len(fields) != 25 { t.Fatalf("fields = %d, want 25", len(fields)) } if fields[0].Name != "Version" || fields[1].Name != "Duration" || fields[2].Name != "Source" || fields[24].Name != "field-21" { t.Fatalf("field order = %+v", fields) } longValue := strings.Repeat("x", v2InlineFieldLimit+1) body = fmt.Sprintf(`{"message":"m","source":"source","extra":[{"name":"long","value":%q}]}`, longValue) payload = sendV2Request(t, "/api/v2/messages", body) if payload.Embeds[0].Fields[0].Name != "Source" || payload.Embeds[0].Fields[1].Inline { t.Fatalf("inline fields = %+v", payload.Embeds[0].Fields) } body = fmt.Sprintf(`{"message":%q,"source":"source","extra":[{"name":"large","value":%q}]}`, strings.Repeat("m", 5000), strings.Repeat("x", 2000)) payload = sendV2Request(t, "/api/v2/messages", body) embed := payload.Embeds[0] if utf8.RuneCountInString(embed.Description) > 4096 || discordEmbedCharacters(payload.Embeds) > 6000 || len(embed.Fields) == 0 || embed.Fields[0].Name != "Source" { t.Fatalf("normalized embed = %+v, characters = %d", embed, discordEmbedCharacters(payload.Embeds)) } } func TestV2FormattingHelpers(t *testing.T) { for _, test := range []struct { bytes int64 want string }{ {0, "0 B"}, {1023, "1023 B"}, {1024, "1.00 KiB"}, {1536, "1.50 KiB"}, {1 << 30, "1.00 GiB"}, } { if got := formatBytes(test.bytes); got != test.want { t.Errorf("formatBytes(%d) = %q, want %q", test.bytes, got, test.want) } } for _, test := range []struct { seconds float64 want string }{ {0, "0s"}, {42.5, "42.5s"}, {60, "1m"}, {125, "2m 5s"}, {125.1, "2m 5.1s"}, {3723, "1h 2m 3s"}, } { if got := formatV2Duration(test.seconds); got != test.want { t.Errorf("formatV2Duration(%v) = %q, want %q", test.seconds, got, test.want) } } for _, value := range []*float64{nil, floatPointer(-1), floatPointer(math.NaN()), floatPointer(math.Inf(1))} { if validNumber(value) { t.Fatalf("validNumber(%v) = true", value) } } } func sendV2Request(t *testing.T, path, body string) discordPayload { t.Helper() recorder := &webhookRecorder{} response := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, path, "application/json", body) if response.Code != http.StatusOK || response.Body.String() != "Notification sent" { t.Fatalf("response = (%d, %q)", response.Code, response.Body.String()) } bodies := recorder.bodies() if len(bodies) != 1 { t.Fatalf("webhook calls = %d, want 1", len(bodies)) } var payload discordPayload if err := json.Unmarshal(bodies[0], &payload); err != nil { t.Fatalf("decode webhook payload: %v", err) } return payload }