package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) func TestBearerTokenSources(t *testing.T) { t.Run("authorization header", func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/v1/home", nil) r.Header.Set("Authorization", "Bearer abc123") if got := bearerToken(r); got != "abc123" { t.Fatalf("got %q, want abc123", got) } }) t.Run("query parameter for image urls", func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/v1/images/1/backdrop?t=abc123", nil) if got := bearerToken(r); got != "abc123" { t.Fatalf("got %q, want abc123", got) } }) t.Run("absent", func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/v1/home", nil) if got := bearerToken(r); got != "" { t.Fatalf("got %q, want empty", got) } }) } func TestHashTokenIsStable(t *testing.T) { a, b := hashToken("token"), hashToken("token") if string(a) != string(b) { t.Fatal("hashing the same token produced different digests") } if string(a) == string(hashToken("other")) { t.Fatal("different tokens hashed to the same digest") } } func TestNewTokenIsUnique(t *testing.T) { seen := map[string]bool{} for range 100 { token, err := newToken() if err != nil { t.Fatalf("newToken: %v", err) } if seen[token] { t.Fatal("newToken repeated a value") } seen[token] = true } } func TestRenameDeviceRejectsBlankName(t *testing.T) { s := &Server{} req := httptest.NewRequest(http.MethodPut, "/v1/auth/devices/tv-1", strings.NewReader(`{"deviceName":" "}`)) req.SetPathValue("deviceID", "tv-1") rec := httptest.NewRecorder() s.handleRenameDevice(rec, req, store.Session{EmbyUserID: "user-1"}) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } } func TestRecommendationOnboardingCandidatesMixMoviesAndSeries(t *testing.T) { items := []recommend.Item{ {ID: "m1", Name: "Movie 1", Type: "Movie", CommunityRating: 9, Genres: []string{"Drama"}}, {ID: "m2", Name: "Movie 2", Type: "Movie", CommunityRating: 8.9, Genres: []string{"Comedy"}}, {ID: "s1", Name: "Series 1", Type: "Series", CommunityRating: 8.8, Genres: []string{"Drama"}}, {ID: "s2", Name: "Series 2", Type: "Series", CommunityRating: 8.7, Genres: []string{"Comedy"}}, {ID: "e1", Name: "Episode", Type: "Episode", CommunityRating: 10}, } got := recommendationOnboardingCandidates(items, 4) if len(got) != 4 { t.Fatalf("candidate count = %d, want 4", len(got)) } for i, kind := range []string{"Movie", "Series", "Movie", "Series"} { if got[i].Type != kind { t.Fatalf("candidate %d type = %q, want %q", i, got[i].Type, kind) } } } func TestRecommendationOnboardingPeopleSeparatesPerformersAndDirectors(t *testing.T) { items := []recommend.Item{{ ID: "arrival", Type: "Movie", CommunityRating: 8.2, People: []recommend.Person{ {ID: "amy", Name: "Amy Adams", Type: "Actor", PrimaryImageTag: "amy-tag"}, {ID: "jeremy", Name: "Jeremy Renner", Type: "Actor"}, {ID: "denis", Name: "Denis Villeneuve", Type: "Director"}, }, }} actors, actresses, directors := recommendationOnboardingPeople(items, 10) if len(actors) != 1 || actors[0].Name != "Jeremy Renner" { t.Fatalf("actors = %#v", actors) } if len(actresses) != 1 || actresses[0].Name != "Amy Adams" || actresses[0].ImageTag != "amy-tag" { t.Fatalf("actresses = %#v", actresses) } if len(directors) != 1 || directors[0].Name != "Denis Villeneuve" { t.Fatalf("directors = %#v", directors) } } func TestRecommendationOnboardingResponseIncludesServerPromptState(t *testing.T) { raw, err := json.Marshal(recommendationOnboardingResponse{Prompted: true}) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"prompted":true`) { t.Fatalf("response = %s", raw) } } func TestEpisodeCodeFormatsPlaybackMetadata(t *testing.T) { item := emby.Summary{Type: "Episode", ParentIndexNumber: 2, IndexNumber: 4} if got := episodeCode(item); got != "S02E04" { t.Fatalf("episode code = %q, want S02E04", got) } if got := episodeCode(emby.Summary{Type: "Movie", IndexNumber: 4}); got != "" { t.Fatalf("movie episode code = %q, want empty", got) } } func TestSonarrScheduleRequiresCapableClient(t *testing.T) { tests := map[string]bool{ "": false, "0.1.53": false, "0.1.54": true, "0.2.0": true, } for version, want := range tests { req := httptest.NewRequest(http.MethodGet, "/v1/home", nil) req.Header.Set("X-Memby-Version", version) if got := supportsSonarrSchedule(req); got != want { t.Errorf("supportsSonarrSchedule(%q) = %v, want %v", version, got, want) } } } func TestRadarrScheduleRequiresMovieScheduleClient(t *testing.T) { tests := map[string]bool{ "": false, "0.1.78": false, "0.1.79": true, "0.2.0": true, } for version, want := range tests { req := httptest.NewRequest(http.MethodGet, "/v1/home", nil) req.Header.Set("X-Memby-Version", version) if got := supportsRadarrSchedule(req); got != want { t.Errorf("supportsRadarrSchedule(%q) = %v, want %v", version, got, want) } } } func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) { req := httptest.NewRequest( http.MethodGet, "/v1/items/42/playback?type=Movie&title=Arrival&resumePositionMs=12000", nil, ) item, ok := playbackHint(req, "42") if !ok { t.Fatal("valid hint was rejected") } if item.ID != "42" || item.Type != "Movie" || item.Name != "Arrival" { t.Fatalf("unexpected hinted item: %+v", item) } if item.UserData.PlaybackPositionTicks != 12_000*ticksPerMillisecond { t.Fatalf("resume ticks = %d", item.UserData.PlaybackPositionTicks) } bad := httptest.NewRequest(http.MethodGet, "/v1/items/42/playback?type=Playlist", nil) if _, ok := playbackHint(bad, "42"); ok { t.Fatal("unsupported type should fall back to Emby") } } func TestEpisodeAfterUsesPositionNotListLength(t *testing.T) { episode := func(id string) json.RawMessage { return json.RawMessage(`{"Id":"` + id + `","Name":"Episode ` + id + `","SeriesName":"Westworld"}`) } t.Run("middle of a season", func(t *testing.T) { items := []json.RawMessage{episode("1"), episode("2"), episode("3")} raw, next, ok := episodeAfter(items, "2") if !ok { t.Fatal("expected an episode after 2") } if next.ID != "3" { t.Fatalf("next = %q, want 3", next.ID) } if got := seriesNameOf(raw); got != "Westworld" { t.Fatalf("series name = %q, want Westworld", got) } }) // Emby drops the leading entry for the first episode, so a two-item list can mean // either "first, second" or "second-to-last, last" depending on where we are. t.Run("first episode has no previous", func(t *testing.T) { items := []json.RawMessage{episode("1"), episode("2")} if _, next, ok := episodeAfter(items, "1"); !ok || next.ID != "2" { t.Fatalf("next = %+v, ok = %v, want episode 2", next, ok) } }) t.Run("series finale has nothing after it", func(t *testing.T) { items := []json.RawMessage{episode("1"), episode("2")} if _, _, ok := episodeAfter(items, "2"); ok { t.Fatal("the last episode must not resolve a next one") } }) t.Run("current episode missing from the result", func(t *testing.T) { items := []json.RawMessage{episode("7"), episode("8")} if _, _, ok := episodeAfter(items, "42"); ok { t.Fatal("an unrelated result must not resolve a next episode") } }) t.Run("empty result", func(t *testing.T) { if _, _, ok := episodeAfter(nil, "1"); ok { t.Fatal("no episodes must not resolve a next one") } }) } // Empty rows must serialise as [] so kotlinx.serialization can decode them into the // client's non-null List fields. func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) { var resp homeResponse ensureSlices(&resp) body, err := json.Marshal(resp) if err != nil { t.Fatalf("marshal: %v", err) } var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } for _, row := range []string{"continueWatching", "nextUp", "favorites", "latestMovies"} { if _, ok := decoded[row].([]any); !ok { t.Fatalf("row %q encoded as %T, want array", row, decoded[row]) } } } func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) { resp := searchHistoryResponse{Queries: []string{}} body, err := json.Marshal(resp) if err != nil { t.Fatalf("marshal: %v", err) } var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } if _, ok := decoded["queries"].([]any); !ok { t.Fatalf("queries encoded as %T, want array", decoded["queries"]) } } // Both routes that write search_history apply one rule, so a query /v1/search records is // exactly one /v1/search/history would have accepted. The length is counted in runes: // bytes would reject a Japanese title at a third of an English one's length. func TestSearchQueryRecordable(t *testing.T) { long := strings.Repeat("a", maxSearchQueryRunes) for _, tc := range []struct { name string term string want bool }{ {"ordinary", "titanic", true}, {"at the floor", "up", true}, {"one letter", "u", false}, {"blank", " ", false}, {"padded is measured trimmed", " up ", true}, {"at the ceiling", long, true}, {"past the ceiling", long + "a", false}, {"multibyte counted as runes", strings.Repeat("あ", maxSearchQueryRunes), true}, } { t.Run(tc.name, func(t *testing.T) { if got := searchQueryRecordable(tc.term); got != tc.want { t.Fatalf("searchQueryRecordable(%q) = %v, want %v", tc.term, got, tc.want) } }) } } // A search whose viewer no longer has a session keeps its row: the query is what the page // is for, and an id still tells one searcher from another. func TestNameSearchEventsKeepsUnattributedRows(t *testing.T) { events := []store.SearchEvent{ {Query: "severance", UserID: "u-1"}, {Query: "dune", UserID: "gone"}, } users := []store.KnownUser{ {ID: "u-1", Username: "matt"}, {ID: "u-2", Username: "sam"}, } got := nameSearchEvents(events, users) if len(got) != 2 { t.Fatalf("event count = %d, want 2", len(got)) } if got[0].Username != "matt" { t.Fatalf("username = %q, want matt", got[0].Username) } if got[1].Username != "" || got[1].Query != "dune" { t.Fatalf("unattributed event = %+v, want an empty name and its query", got[1]) } } // The page must not offer a window the table cannot fill: RecordSearch prunes to the // retention period, so a wider one would draw a flat line for the difference. func TestSearchWindowMatchesRetention(t *testing.T) { if searchWindowDays != 30 { t.Fatalf("search window = %d days, want 30 to match store.SearchRetention", searchWindowDays) } } // Recording must never be what stops a search being answered. A gateway with no database // reaches this on every keystroke, so the guard comes before anything that could panic on // a half-built server. func TestRecordSearchQueryWithoutStoreIsSilent(t *testing.T) { s := &Server{} s.recordSearchQuery(context.Background(), store.Session{EmbyUserID: "u1"}, "titanic") } // The fixed rows must keep their order, ids and kinds: the client maps kinds onto // card shapes and uses ids as Compose keys. func TestBaseRowsShape(t *testing.T) { rows := baseRows(homeResponse{ ContinueWatching: []json.RawMessage{json.RawMessage(`{"Id":"1"}`)}, Favorites: []json.RawMessage{json.RawMessage(`{"Id":"2"}`)}, }) if len(rows) != 3 { t.Fatalf("expected 3 base rows, got %d", len(rows)) } wantIDs := []string{"continue", "favorites", "latest-movies"} wantKinds := []string{"continue", "favorites", "latest"} for i, row := range rows { if row.ID != wantIDs[i] { t.Fatalf("row %d id = %q, want %q", i, row.ID, wantIDs[i]) } if row.Kind != wantKinds[i] { t.Fatalf("row %d kind = %q, want %q", i, row.Kind, wantKinds[i]) } if row.Title == "" { t.Fatalf("row %d has no title", i) } } // The favourites row carries the items the client used to assemble itself. if len(rows[1].Items) != 1 { t.Fatalf("favourites row lost its items: %+v", rows[1]) } } func TestHomeForYouWindowAt(t *testing.T) { tests := []struct { hour int id string minutes int }{ {hour: 4, id: "late-night", minutes: 60}, {hour: 5, id: "morning", minutes: 30}, {hour: 11, id: "morning", minutes: 30}, {hour: 12, id: "afternoon", minutes: 60}, {hour: 16, id: "afternoon", minutes: 60}, {hour: 17, id: "evening", minutes: 120}, {hour: 22, id: "evening", minutes: 120}, {hour: 23, id: "late-night", minutes: 60}, } for _, test := range tests { got := homeForYouWindowAt(time.Date(2026, time.July, 29, test.hour, 0, 0, 0, time.UTC)) if got.ID != test.id || got.Minutes != test.minutes { t.Errorf("hour %d: got %#v, want id=%q minutes=%d", test.hour, got, test.id, test.minutes) } } } func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) { var builds recommendationBuilds if !builds.begin("user-1") { t.Fatal("first build should be allowed to start") } if builds.begin("user-1") { t.Fatal("a second concurrent build for the same user must be skipped") } if !builds.begin("user-2") { t.Fatal("a different user must not be blocked") } builds.done("user-1") if !builds.begin("user-1") { t.Fatal("a build should be allowed again once the previous one finished") } } func TestSummariseReadsResumePosition(t *testing.T) { raw := json.RawMessage(`{"Id":"42","Name":"Arrival","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}`) summary, err := emby.Summarise(raw) if err != nil { t.Fatalf("summarise: %v", err) } if summary.ID != "42" || summary.Name != "Arrival" { t.Fatalf("unexpected summary: %+v", summary) } if got := summary.UserData.PlaybackPositionTicks / ticksPerMillisecond; got != 3_600_000 { t.Fatalf("resume position = %d ms, want 3600000", got) } } func TestMergeClientIdentityPersistsReportedHeaders(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) req.Header.Set("X-Memby-Version", "0.1.60") req.Header.Set("X-Memby-Protocol", "1") sess := store.Session{} if changed := mergeClientIdentity(req, &sess); !changed { t.Fatal("reported identity should update the session") } if sess.ClientVersion != "0.1.60" || sess.ClientProtocol != "1" { t.Fatalf("session identity = %q/%q", sess.ClientVersion, sess.ClientProtocol) } } func TestMergeClientIdentityAttributesHeaderlessAuthenticatedRequest(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/images/42/primary?t=token", nil) sess := store.Session{ClientVersion: "0.1.60", ClientProtocol: "1"} if changed := mergeClientIdentity(req, &sess); changed { t.Fatal("inheriting identity should not write the session again") } if got := clientVersion(req); got != "0.1.60" { t.Fatalf("inherited client version = %q", got) } if got := clientProtocol(req); got != "1" { t.Fatalf("inherited client protocol = %q", got) } }