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