adding test containers
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled

This commit is contained in:
2026-07-16 09:14:51 -03:00
parent 0043c4ae00
commit b1cdf15cc8
19 changed files with 2390 additions and 298 deletions

View File

@@ -0,0 +1,243 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
)
func TestDiscordDeliveryRetries(t *testing.T) {
tests := []struct {
name string
statuses []int
headers []http.Header
transportErr error
wantCalls int
wantSleeps []time.Duration
wantError error
}{
{name: "success", statuses: []int{204}, wantCalls: 1},
{name: "all 2xx accepted", statuses: []int{299}, wantCalls: 1},
{name: "permanent 4xx", statuses: []int{400}, wantCalls: 1, wantError: errWebhookDelivery},
{name: "5xx recovers", statuses: []int{500, 204}, wantCalls: 2, wantSleeps: []time.Duration{250 * time.Millisecond}},
{name: "5xx exhausts", statuses: []int{500, 502, 503}, wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookDelivery},
{name: "429 honors capped retry after", statuses: []int{429, 204}, headers: []http.Header{{"Retry-After": []string{"10"}}, nil}, wantCalls: 2, wantSleeps: []time.Duration{2 * time.Second}},
{name: "429 uses reset after", statuses: []int{429, 204}, headers: []http.Header{{"X-Ratelimit-Reset-After": []string{"0.125"}}, nil}, wantCalls: 2, wantSleeps: []time.Duration{125 * time.Millisecond}},
{name: "transport errors exhaust", transportErr: errors.New("connection reset"), wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookDelivery},
{name: "timeouts exhaust", transportErr: context.DeadlineExceeded, wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookTimeout},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var mu sync.Mutex
calls := 0
var sleeps []time.Duration
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
mu.Lock()
defer mu.Unlock()
calls++
if request.Header.Get("Content-Type") != "application/json" {
t.Errorf("Content-Type = %q", request.Header.Get("Content-Type"))
}
if test.transportErr != nil {
return nil, test.transportErr
}
index := min(calls-1, len(test.statuses)-1)
var headers http.Header
if index < len(test.headers) {
headers = test.headers[index]
}
return response(test.statuses[index], headers), nil
})
client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(_ context.Context, delay time.Duration) error {
sleeps = append(sleeps, delay)
return nil
})
if err != nil {
t.Fatal(err)
}
err = client.send(context.Background(), discordPayload{Content: "hello"})
if !errors.Is(err, test.wantError) {
t.Fatalf("send() error = %v, want %v", err, test.wantError)
}
if calls != test.wantCalls {
t.Fatalf("calls = %d, want %d", calls, test.wantCalls)
}
if len(sleeps) != len(test.wantSleeps) {
t.Fatalf("sleeps = %v, want %v", sleeps, test.wantSleeps)
}
for index := range sleeps {
if sleeps[index] != test.wantSleeps[index] {
t.Fatalf("sleeps = %v, want %v", sleeps, test.wantSleeps)
}
}
})
}
}
func TestDiscordDeliveryPayloadAndCanceledSleep(t *testing.T) {
var sent discordPayload
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if err := json.NewDecoder(request.Body).Decode(&sent); err != nil {
t.Fatal(err)
}
return response(204, nil), nil
})
client, err := newDiscordClient("http://discord.invalid/webhook", &http.Client{Transport: transport}, nil)
if err != nil {
t.Fatal(err)
}
if err := client.send(context.Background(), discordPayload{Content: strings.Repeat("界", 2100)}); err != nil {
t.Fatal(err)
}
if utf8.RuneCountInString(sent.Content) != 2000 || !strings.HasSuffix(sent.Content, truncationMarker) {
t.Fatalf("content length/suffix = %d/%q", utf8.RuneCountInString(sent.Content), []rune(sent.Content)[1980:])
}
if sent.AllowedMentions.Parse == nil || len(sent.AllowedMentions.Parse) != 0 {
t.Fatalf("allowed mentions = %#v", sent.AllowedMentions)
}
client, err = newDiscordClient("http://discord.invalid/webhook", &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(500, nil), nil
})}, func(context.Context, time.Duration) error { return context.DeadlineExceeded })
if err != nil {
t.Fatal(err)
}
if err := client.send(context.Background(), discordPayload{Content: "x"}); !errors.Is(err, errWebhookTimeout) {
t.Fatalf("send() error = %v, want timeout", err)
}
}
func TestDiscordDeliveryRecoversFromTransportError(t *testing.T) {
var calls int
var sleeps []time.Duration
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return nil, errors.New("temporary connection failure")
}
return response(http.StatusNoContent, nil), nil
})
client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(_ context.Context, delay time.Duration) error {
sleeps = append(sleeps, delay)
return nil
})
if err != nil {
t.Fatal(err)
}
if err := client.send(context.Background(), discordPayload{Content: "hello"}); err != nil {
t.Fatal(err)
}
if calls != 2 || len(sleeps) != 1 || sleeps[0] != 250*time.Millisecond {
t.Fatalf("calls/sleeps = %d/%v", calls, sleeps)
}
}
func TestDiscordDeliveryHonorsCallerCancellation(t *testing.T) {
var calls, sleeps int
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
calls++
return nil, request.Context().Err()
})
client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(context.Context, time.Duration) error {
sleeps++
return nil
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := client.send(ctx, discordPayload{Content: "hello"}); !errors.Is(err, errWebhookTimeout) {
t.Fatalf("send() error = %v, want timeout", err)
}
if calls > 1 || sleeps != 0 {
t.Fatalf("canceled delivery made %d calls and %d sleeps", calls, sleeps)
}
}
func TestRetryDelayParsing(t *testing.T) {
if got, ok := parseRetryDelay("1.5"); !ok || got != 1500*time.Millisecond {
t.Fatalf("parseRetryDelay() = %v, %v", got, ok)
}
if _, ok := parseRetryDelay("garbage"); ok {
t.Fatal("invalid retry delay accepted")
}
httpDate := time.Now().Add(time.Second).UTC().Format(http.TimeFormat)
if got, ok := parseRetryDelay(httpDate); !ok || got < 0 || got > time.Second {
t.Fatalf("HTTP-date retry delay = %v, %v", got, ok)
}
if got := fallbackRetryDelay(1); got != 500*time.Millisecond {
t.Fatalf("fallbackRetryDelay(1) = %v", got)
}
if retryableStatus(499) || !retryableStatus(500) || !retryableStatus(429) {
t.Fatal("retryableStatus returned unexpected result")
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if err := sleepContext(canceled, time.Hour); !errors.Is(err, context.Canceled) {
t.Fatalf("sleepContext() error = %v", err)
}
}
func TestNormalizeDiscordPayloadLimits(t *testing.T) {
fields := make([]discordEmbedField, 30)
for index := range fields {
fields[index] = discordEmbedField{
Name: strings.Repeat("n", 300),
Value: strings.Repeat("界", 1200),
}
}
payload := discordPayload{
Content: strings.Repeat("c", 2100),
Embeds: []discordEmbed{{
Title: strings.Repeat("t", 300),
Description: strings.Repeat("d", 5000),
Fields: fields,
Footer: &discordEmbedFooter{Text: strings.Repeat("f", 2200)},
}},
}
normalizeDiscordPayload(&payload)
if utf8.RuneCountInString(payload.Content) != 2000 || utf8.RuneCountInString(payload.Embeds[0].Title) > 256 || utf8.RuneCountInString(payload.Embeds[0].Description) > 4096 {
t.Fatal("individual limits were not applied")
}
if len(payload.Embeds[0].Fields) > 25 || discordEmbedCharacters(payload.Embeds) > 6000 {
t.Fatalf("embed limits = fields %d, chars %d", len(payload.Embeds[0].Fields), discordEmbedCharacters(payload.Embeds))
}
for _, field := range payload.Embeds[0].Fields {
if utf8.RuneCountInString(field.Name) > 256 || utf8.RuneCountInString(field.Value) > 1024 {
t.Fatal("field limit was not applied")
}
}
if got := truncateRunes("abcdef", 4); got != "… [t" {
t.Fatalf("small truncation = %q", got)
}
if got := truncateRunes("abc", 3); got != "abc" {
t.Fatalf("unneeded truncation = %q", got)
}
if got := truncateRunes("abc", 0); got != "" {
t.Fatalf("zero truncation = %q", got)
}
}
func TestDiscordClientRejectsInvalidURLs(t *testing.T) {
for _, value := range []string{"", "relative/path", "ftp://example.com/hook", "https:///hook"} {
if _, err := newDiscordClient(value, nil, nil); err == nil {
t.Fatalf("newDiscordClient(%q) succeeded", value)
}
}
}
func TestIsTimeout(t *testing.T) {
if !isTimeout(context.DeadlineExceeded) || isTimeout(errors.New("ordinary")) {
t.Fatal("isTimeout returned unexpected result")
}
}