package api import ( "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/ponzischeme89/memby/server/internal/config" ) func importPayload(movieID, fileID int, title string, year int) radarrWebhookPayload { var payload radarrWebhookPayload payload.EventType = "Download" payload.Movie.ID = movieID payload.Movie.Title = title payload.Movie.Year = year payload.MovieFile.ID = fileID return payload } func TestRadarrImportAlertAnnouncesANewFilm(t *testing.T) { now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) alert, ok := radarrImportAlert(importPayload(412, 9001, "Mr. Smith Goes to Washington", 1939), now) if !ok { t.Fatal("expected an import to be announced") } if alert.ID != "radarr:412:file:9001" { t.Errorf("alert id = %q, want it keyed on the imported file", alert.ID) } if alert.Kind != alertKindRadarrImport { t.Errorf("kind = %q, want %q", alert.Kind, alertKindRadarrImport) } if alert.Label == "" { t.Error("want a label: the app cannot know the wording for a kind it predates") } if alert.Title != "Mr. Smith Goes to Washington (1939)" { t.Errorf("title = %q, want the year alongside it", alert.Title) } if !strings.Contains(alert.Message, "available in Emby shortly") { t.Errorf("message = %q, want it to say the film is on its way", alert.Message) } // The image proxy serves Radarr covers under this pair, so the banner has a poster // before Emby has scanned the film in. if alert.ItemID != "radarr:412" || alert.ImageTag != "radarr" { t.Errorf("artwork = %q/%q, want the radarr media cover", alert.ItemID, alert.ImageTag) } if alert.AiredAt != now.Format(time.RFC3339) { t.Errorf("airedAt = %q, want the import time so it sorts with the rest", alert.AiredAt) } } func TestRadarrImportAlertIgnoresEventsThatAreNotANewFilm(t *testing.T) { now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) upgrade := importPayload(412, 9002, "Mr. Smith Goes to Washington", 1939) upgrade.IsUpgrade = true grab := importPayload(413, 0, "Some Film", 2024) grab.EventType = "Grab" untitled := importPayload(414, 9003, " ", 2024) unknownMovie := importPayload(0, 9004, "No Id", 2024) for name, payload := range map[string]radarrWebhookPayload{ "quality upgrade of a film already there": upgrade, "grabbed but not imported": grab, "no title": untitled, "no movie id": unknownMovie, } { if _, ok := radarrImportAlert(payload, now); ok { t.Errorf("%s: expected no alert", name) } } } func TestRadarrImportAlertAcceptsTheNewerEventName(t *testing.T) { now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) payload := importPayload(415, 9005, "Rear Window", 1954) payload.EventType = "MovieFileImported" if _, ok := radarrImportAlert(payload, now); !ok { t.Error("expected the alternate import event name to be announced") } } // A file id is what makes a repeated notification the same news; without one the id // falls back to the clock so a re-import is not silently swallowed. func TestRadarrImportAlertWithoutAFileIDIsStillAnnounced(t *testing.T) { now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) alert, ok := radarrImportAlert(importPayload(416, 0, "Sabotage", 1936), now) if !ok { t.Fatal("expected an alert") } if !strings.HasPrefix(alert.ID, "radarr:416:imported:") { t.Errorf("alert id = %q, want a time-keyed fallback", alert.ID) } } 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)) }