279 lines
9.0 KiB
Go
279 lines
9.0 KiB
Go
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))
|
||
|
|
}
|