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")
|
|
|
|
|
}
|
|
|
|
|
}
|