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

160 lines
5.8 KiB
Go
Raw Normal View History

2026-08-03 08:52:55 +12:00
package api
import (
2026-08-06 22:33:56 +12:00
"encoding/json"
2026-08-03 08:52:55 +12:00
"testing"
2026-08-06 22:33:56 +12:00
"time"
2026-08-03 08:52:55 +12:00
"github.com/ponzischeme89/memby/server/internal/mdblist"
2026-08-06 22:33:56 +12:00
"github.com/ponzischeme89/memby/server/internal/store"
2026-08-03 08:52:55 +12:00
)
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)
}
2026-08-06 22:33:56 +12:00
if got[0] != (movieRating{Source: "letterboxd", Name: "Letterboxd", Score: "4.2", Scale: "/5"}) ||
2026-08-03 08:52:55 +12:00
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)
}
}
2026-08-06 22:33:56 +12:00
// 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)
}
}
}
2026-08-03 08:52:55 +12:00
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)
}
}
2026-08-06 22:33:56 +12:00
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")
}
}