package api import ( "encoding/json" "testing" "time" "github.com/ponzischeme89/memby/server/internal/mdblist" "github.com/ponzischeme89/memby/server/internal/store" ) func TestSelectedMovieRatingsFiltersOrdersAndLabelsAvailableValues(t *testing.T) { got := selectedMovieRatings( []string{"letterboxd", "imdb", "tomatoes", "rogerebert", "metacritic"}, []mdblist.Rating{ {Source: "imdb", Value: 8.2}, {Source: "rttomatoes", Value: 91}, {Source: "letterboxd", Value: 4.15}, {Source: "rogerebert", Value: 0}, {Source: "metacritic", Value: 101}, }, ) if len(got) != 3 { t.Fatalf("ratings = %+v", got) } if got[0] != (movieRating{Source: "letterboxd", Name: "Letterboxd", Score: "4.2", Scale: "/5"}) || got[1].Name != "IMDb" || got[1].Score != "8.2" || got[1].Scale != "/10" || got[2].Name != "Rotten Tomatoes" || got[2].Score != "91" || got[2].Scale != "%" { t.Fatalf("formatted ratings = %+v", got) } } // A round score is the case the strip is read on: "7" beside "8.2" looks like a score out // of a different scale, so a fractional scale keeps its decimal and a whole one never // gains one. func TestFormatRatingScoreKeepsTheDecimalOfAFractionalScale(t *testing.T) { for _, tc := range []struct { source string value float64 want string }{ {"imdb", 7, "7.0"}, {"imdb", 8.26, "8.3"}, {"letterboxd", 4, "4.0"}, {"tomatoes", 91, "91"}, {"metacritic", 87.4, "87"}, } { if got := formatRatingScore(tc.value, movieRatingSources[tc.source]); got != tc.want { t.Fatalf("%s %v = %q, want %q", tc.source, tc.value, got, tc.want) } } } func TestMovieProviderPrefersTMDBAndFallsBackToIMDb(t *testing.T) { provider, id := movieProvider(map[string]string{"ImDb": "tt0111161", "TmDb": "278"}) if provider != "tmdb" || id != "278" { t.Fatalf("provider = %q %q", provider, id) } provider, id = movieProvider(map[string]string{"IMDb": "tt0111161"}) if provider != "imdb" || id != "tt0111161" { t.Fatalf("fallback provider = %q %q", provider, id) } } func TestRatingsNeedRefreshServesStoredValuesForAMonthAndRetriesEmptyOnesSooner(t *testing.T) { now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) scored := []mdblist.Rating{{Source: "imdb", Value: 8.2}} if ratingsNeedRefresh(scored, now.Add(-29*24*time.Hour), now) { t.Fatal("a month-old score should still be considered current") } if !ratingsNeedRefresh(scored, now.Add(-31*24*time.Hour), now) { t.Fatal("an expired score should be refreshed") } // Nothing found is worth asking about again sooner: a new release gains scores. if ratingsNeedRefresh(nil, now.Add(-2*24*time.Hour), now) { t.Fatal("a recent empty answer should be kept") } if !ratingsNeedRefresh(nil, now.Add(-4*24*time.Hour), now) { t.Fatal("an old empty answer should be retried") } if !ratingsNeedRefresh(scored, time.Time{}, now) { t.Fatal("an unknown fetch time should be refreshed") } } func TestRatingKeyForRatesEpisodesAsTheirSeriesAndPrefersTMDB(t *testing.T) { key, ok := ratingKeyFor("Episode", map[string]string{"Tmdb": "1396", "Imdb": "tt0903747"}) if !ok || key != (store.RatingKey{MediaType: "show", Provider: "tmdb", ProviderID: "1396"}) { t.Fatalf("episode key = %+v %v", key, ok) } key, ok = ratingKeyFor("Movie", map[string]string{"IMDB": "tt0111161"}) if !ok || key != (store.RatingKey{MediaType: "movie", Provider: "imdb", ProviderID: "tt0111161"}) { t.Fatalf("movie key = %+v %v", key, ok) } if _, ok := ratingKeyFor("Movie", map[string]string{"Tvdb": "1234"}); ok { t.Fatal("an item MDBList cannot be asked about must not produce a key") } if _, ok := ratingKeyFor("BoxSet", map[string]string{"Tmdb": "9"}); ok { t.Fatal("only films and shows are rated") } } func TestInjectItemRatingsKeepsEveryOtherFieldOfEmbysPayload(t *testing.T) { raw := json.RawMessage(`{"Id":"42","Name":"Arrival","UnknownToThisBuild":{"a":1}}`) out := injectItemRatings(raw, []movieRating{ {Source: "imdb", Name: "IMDb", Score: "7.9", Scale: "/10"}, }) var decoded map[string]json.RawMessage if err := json.Unmarshal(out, &decoded); err != nil { t.Fatalf("decode: %v", err) } if string(decoded["UnknownToThisBuild"]) != `{"a":1}` || string(decoded["Name"]) != `"Arrival"` { t.Fatalf("payload lost fields: %s", out) } var ratings []movieRating if json.Unmarshal(decoded[ratingsItemField], &ratings) != nil || len(ratings) != 1 || ratings[0].Name != "IMDb" { t.Fatalf("ratings = %s", decoded[ratingsItemField]) } // Nothing to say is said by saying nothing: an empty list must not add the field. if got := injectItemRatings(raw, nil); string(got) != string(raw) { t.Fatalf("empty ratings changed the payload: %s", got) } } func TestItemIDsInDeduplicatesAcrossRowsAndStopsAtTheLimit(t *testing.T) { item := func(id string) json.RawMessage { return json.RawMessage(`{"Id":"` + id + `"}`) } ids := itemIDsIn([][]json.RawMessage{ {item("a"), item("b")}, {item("b"), item("c"), json.RawMessage(`{"Name":"no id"}`)}, }, 10) if len(ids) != 3 || ids[0] != "a" || ids[2] != "c" { t.Fatalf("ids = %v", ids) } if capped := itemIDsIn([][]json.RawMessage{{item("a"), item("b"), item("c")}}, 2); len(capped) != 2 { t.Fatalf("capped = %v", capped) } } func TestClaimRatingsBudgetLimitsTheDayAndHonoursABackoff(t *testing.T) { s := &Server{} now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) for spent := 0; spent < ratingsWarmDailyBudget; spent++ { if !s.claimRatingsBudget(now) { t.Fatalf("budget refused at %d", spent) } } if s.claimRatingsBudget(now) { t.Fatal("the daily budget should be spent") } // A new day restores it. if !s.claimRatingsBudget(now.Add(25 * time.Hour)) { t.Fatal("a new window should restore the budget") } s.blockRatingsWarming(now.Add(26 * time.Hour)) if s.claimRatingsBudget(now.Add(25*time.Hour + time.Minute)) { t.Fatal("a refused provider should stop the warmer entirely") } }