Files
memby/server/internal/api/radarr_alerts_test.go
T

189 lines
5.8 KiB
Go
Raw Normal View History

2026-08-02 22:10:19 +12:00
package api
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestAppendAlertPrunesExpiredAndDeduplicates(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
existing := []storedAlert{
{
Alert: clientAlert{ID: "radarr:1:file:1", AiredAt: now.Add(-4 * time.Hour).Format(time.RFC3339)},
ExpiresAt: now.Add(-time.Minute),
},
{
Alert: clientAlert{ID: "radarr:2:file:2", AiredAt: now.Add(-time.Hour).Format(time.RFC3339)},
ExpiresAt: now.Add(2 * time.Hour),
},
{
Alert: clientAlert{ID: "radarr:3:file:3", AiredAt: now.Add(-2 * time.Hour).Format(time.RFC3339)},
ExpiresAt: now.Add(time.Hour),
},
}
// Radarr delivering the same import twice must not stack two banners.
repeat := clientAlert{ID: "radarr:3:file:3", AiredAt: now.Format(time.RFC3339)}
stored := appendAlert(existing, repeat, now.Add(3*time.Hour), now)
if len(stored) != 2 {
t.Fatalf("expected 2 stored alerts, got %d: %+v", len(stored), stored)
}
if stored[0].Alert.ID != "radarr:3:file:3" {
t.Errorf("newest first: got %q", stored[0].Alert.ID)
}
if stored[1].Alert.ID != "radarr:2:file:2" {
t.Errorf("expected the unexpired older alert to survive, got %q", stored[1].Alert.ID)
}
}
func TestAppendAlertKeepsOnlyTheNewestFew(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
var stored []storedAlert
// A bulk import: many films land at once and every one of them is current.
for i := 0; i < maxStoredAlerts+5; i++ {
alert := clientAlert{
ID: "radarr:" + time.Duration(i).String(),
AiredAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
}
stored = appendAlert(stored, alert, now.Add(3*time.Hour), now)
}
if len(stored) != maxStoredAlerts {
t.Fatalf("stored %d alerts, want a cap of %d", len(stored), maxStoredAlerts)
}
}
func TestLiveAlertsDropsClosedWindows(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alerts := liveAlerts([]storedAlert{
{Alert: clientAlert{ID: "current"}, ExpiresAt: now.Add(time.Minute)},
{Alert: clientAlert{ID: "stale"}, ExpiresAt: now.Add(-time.Second)},
}, now)
if len(alerts) != 1 || alerts[0].ID != "current" {
t.Fatalf("expected only the current alert, got %+v", alerts)
}
}
func TestMergeAlertsOrdersNewestFirstAcrossSources(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
at := func(d time.Duration) string { return now.Add(d).Format(time.RFC3339) }
merged := mergeAlerts(
[]clientAlert{{ID: "movie-recent", AiredAt: at(-10 * time.Minute)}},
[]clientAlert{
{ID: "episode-older", AiredAt: at(-2 * time.Hour)},
{ID: "episode-newest", AiredAt: at(-time.Minute)},
},
)
want := []string{"episode-newest", "movie-recent", "episode-older"}
for i, id := range want {
if merged[i].ID != id {
t.Fatalf("merged[%d] = %q, want %q (%+v)", i, merged[i].ID, id, merged)
}
}
}
func TestMergeAlertsCapsWhatOneTVIsShown(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
var many []clientAlert
for i := 0; i < maxAlerts+3; i++ {
many = append(many, clientAlert{
ID: "alert-" + time.Duration(i).String(),
AiredAt: now.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339),
})
}
if got := len(mergeAlerts(many)); got != maxAlerts {
t.Fatalf("merged %d alerts, want a cap of %d", got, maxAlerts)
}
}
func TestWebhookTokenIsReadFromEveryFormRadarrCanSend(t *testing.T) {
for name, build := range map[string]func() *http.Request{
"header": func() *http.Request {
r := newWebhookRequest("/hooks/radarr")
r.Header.Set("X-Memby-Token", "secret")
return r
},
"bearer": func() *http.Request {
r := newWebhookRequest("/hooks/radarr")
r.Header.Set("Authorization", "Bearer secret")
return r
},
"basic auth": func() *http.Request {
r := newWebhookRequest("/hooks/radarr")
r.SetBasicAuth("memby", "secret")
return r
},
"query": func() *http.Request {
return newWebhookRequest("/hooks/radarr?token=secret")
},
} {
if got := webhookToken(build()); got != "secret" {
t.Errorf("%s: token = %q, want %q", name, got, "secret")
}
}
}
func newWebhookRequest(target string) *http.Request {
return httptest.NewRequest(http.MethodPost, target, strings.NewReader("{}"))
}
func TestRadarrWebhookIsHiddenUntilATokenIsConfigured(t *testing.T) {
s := &Server{cfg: config.Config{}, log: discardLogger()}
rec := httptest.NewRecorder()
s.handleRadarrWebhook(rec, newWebhookRequest("/hooks/radarr?token=anything"))
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404 for an unconfigured hook", rec.Code)
}
}
func TestRadarrWebhookRejectsAWrongToken(t *testing.T) {
s := &Server{
cfg: config.Config{RadarrWebhookToken: "hook-secret", RadarrAlertWindow: time.Hour},
log: discardLogger(),
}
rec := httptest.NewRecorder()
s.handleRadarrWebhook(rec, newWebhookRequest("/hooks/radarr?token=guess"))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
}
// Radarr's Test button has to succeed without putting a film that does not exist on
// every television in the house.
func TestRadarrWebhookTestEventAnnouncesNothing(t *testing.T) {
s := &Server{
cfg: config.Config{RadarrWebhookToken: "hook-secret", RadarrAlertWindow: time.Hour},
log: discardLogger(),
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost, "/hooks/radarr?token=hook-secret",
strings.NewReader(`{"eventType":"Test","movie":{"id":1,"title":"Test Title"}}`),
)
// A nil cache would panic if this reached the store, which is the assertion.
s.handleRadarrWebhook(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}