package backend import ( "bytes" "context" "encoding/json" "image" "image/png" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "music-server/internal/db/repository" "music-server/internal/testutil/fakedb" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func pngBytes(t *testing.T) []byte { t.Helper() var buf bytes.Buffer require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3)))) return buf.Bytes() } // coverAPIs fakes SteamGridDB, AniList and the image host. type coverAPIs struct { t *testing.T server *httptest.Server // games maps search terms to SteamGridDB game IDs; 404 has no grids. games map[string]int // anime maps search terms to AniList titles; the ID is always 42. anime map[string]string // failSteamGridDB makes SteamGridDB answer 500. failSteamGridDB bool mu sync.Mutex searches []string } func newCoverAPIs(t *testing.T) *coverAPIs { a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}} image := pngBytes(t) mux := http.NewServeMux() reply := func(w http.ResponseWriter, data any) { _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data}) } mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) { a.record("sgdb:" + r.PathValue("term")) if a.failSteamGridDB { http.Error(w, "down", http.StatusInternalServerError) return } assert.Equal(t, "Bearer key", r.Header.Get("Authorization")) if id, ok := a.games[r.PathValue("term")]; ok { reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}}) return } reply(w, []any{}) }) mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "600x900", r.URL.Query().Get("dimensions")) if r.PathValue("id") == "404" { reply(w, []any{}) return } reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}}) }) mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) { var req struct { Variables struct { Search string `json:"search"` ID int `json:"id"` } `json:"variables"` } require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) a.record("anilist:" + req.Variables.Search) title, ok := a.anime[req.Variables.Search] if req.Variables.ID == 42 { title, ok = "By ID", true } if !ok { _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{ "id": 42, "title": map[string]any{"romaji": title}, "coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"}, }}}) }) mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) { if r.PathValue("name") == "text.png" { _, _ = w.Write([]byte("not an image")) return } _, _ = w.Write(image) }) a.server = httptest.NewServer(mux) t.Cleanup(a.server.Close) return a } func (a *coverAPIs) record(search string) { a.mu.Lock() defer a.mu.Unlock() a.searches = append(a.searches, search) } func (a *coverAPIs) searched() []string { a.mu.Lock() defer a.mu.Unlock() return append([]string(nil), a.searches...) } func (a *coverAPIs) options(withKey bool) CoverOptions { opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"} if withKey { opts.SteamGridDBKey = "key" } return opts } func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService { t.Helper() c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts) require.NoError(t, err) c.interval = 0 return c } func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack { t.Helper() st, ok := store.Soundtrack(name) require.True(t, ok) return st } func TestCoverSearchFindsCovers(t *testing.T) { apis := newCoverAPIs(t) apis.games["Chrono Trigger"] = 1 apis.games["FF7"] = 7 apis.games["Lemmings"] = 3 apis.anime["Cowboy Bebop"] = "Cowboy Bebop" apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children" store := fakedb.New() store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"}) ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"}) c := newCovers(t, store, apis.options(true)) require.NoError(t, c.searchMissing(context.Background())) chrono := coverOf(t, store, "Chrono Trigger") require.NotNil(t, chrono.CoverFile) assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile) assert.Equal(t, "steamgriddb", *chrono.CoverSource) assert.Equal(t, "1", *chrono.CoverExternalID) assert.False(t, chrono.CoverManual) assert.True(t, chrono.CoverModified.Valid) assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile)) assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name") assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets") bebop := coverOf(t, store, "Cowboy Bebop") assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing") unknown := coverOf(t, store, "Unknown") assert.Nil(t, unknown.CoverFile) assert.True(t, unknown.CoverChecked.Valid, "marked as searched") assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first") _ = ff // Searched soundtracks are not searched again until the recheck time. before := len(apis.searched()) require.NoError(t, c.searchMissing(context.Background())) assert.Len(t, apis.searched(), before) } func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) { apis := newCoverAPIs(t) apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children" store := fakedb.New() store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"}) c := newCovers(t, store, apis.options(true)) require.NoError(t, c.searchMissing(context.Background())) assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used") } func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) { apis := newCoverAPIs(t) apis.failSteamGridDB = true store := fakedb.New() store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"}) c := newCovers(t, store, apis.options(true)) err := c.searchMissing(context.Background()) assert.ErrorIs(t, err, ErrUpstream) assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried") assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time") } func TestCoverSearchSkipsManualCovers(t *testing.T) { apis := newCoverAPIs(t) apis.games["Chrono Trigger"] = 1 store := fakedb.New() st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"}) c := newCovers(t, store, apis.options(true)) require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t))) require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op require.NoError(t, c.searchMissing(context.Background())) assert.Empty(t, apis.searched()) assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource) // A cover set by hand while the job was searching is kept. stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1") require.NoError(t, err) assert.False(t, stored) assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource) } func TestCoverSearchAgainAfterRecheckTime(t *testing.T) { apis := newCoverAPIs(t) store := fakedb.New() store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old", CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent", CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}) c := newCovers(t, store, apis.options(true)) require.NoError(t, c.searchMissing(context.Background())) assert.Contains(t, apis.searched(), "sgdb:Old") assert.NotContains(t, apis.searched(), "sgdb:Recent") } func TestCoverUpload(t *testing.T) { store := fakedb.New() st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) c := newCovers(t, store, CoverOptions{}) ctx := context.Background() jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...) require.NoError(t, c.Upload(ctx, st.Uuid, jpeg)) got := coverOf(t, store, "Game") assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile) assert.True(t, got.CoverManual) assert.Nil(t, got.CoverExternalID) path, err := c.Path(ctx, st.Uuid) require.NoError(t, err) data, _ := os.ReadFile(path) assert.Equal(t, jpeg, data) // A PNG replaces the JPEG, and its old file is removed. require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t))) assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile) assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg")) assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("")), ErrInvalidImage) assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage) assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound) entries, _ := os.ReadDir(c.dir) assert.Len(t, entries, 1, "no temporary files are left behind") } func TestCoverFromSource(t *testing.T) { apis := newCoverAPIs(t) store := fakedb.New() st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) ctx := context.Background() c := newCovers(t, store, apis.options(true)) require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5")) got := coverOf(t, store, "Game") assert.Equal(t, "steamgriddb", *got.CoverSource) assert.Equal(t, "5", *got.CoverExternalID) assert.True(t, got.CoverManual, "a picked cover is never replaced by the job") require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42")) assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource) assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids") assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound) assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound) assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName) assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound) withoutKey := newCovers(t, store, apis.options(false)) assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName) require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key") } func TestCoverDownloadMustBeAnImage(t *testing.T) { apis := newCoverAPIs(t) c := newCovers(t, fakedb.New(), apis.options(true)) _, err := c.download(context.Background(), apis.server.URL+"/img/text.png") assert.ErrorIs(t, err, ErrUpstream) } func TestCoverRemove(t *testing.T) { store := fakedb.New() st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) c := newCovers(t, store, CoverOptions{}) ctx := context.Background() require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t))) require.NoError(t, c.Remove(ctx, st.Uuid)) got := coverOf(t, store, "Game") assert.Nil(t, got.CoverFile) assert.False(t, got.CoverManual) assert.False(t, got.CoverChecked.Valid, "the job searches for it again") assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png")) _, err := c.Path(ctx, st.Uuid) assert.ErrorIs(t, err, ErrNotFound) assert.Len(t, c.trigger, 1, "the job is asked to search") } func TestCoverRemoveOrphans(t *testing.T) { store := fakedb.New() st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) c := newCovers(t, store, CoverOptions{}) require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t))) writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset") c.removeOrphans(context.Background()) entries, _ := os.ReadDir(c.dir) require.Len(t, entries, 1) assert.Equal(t, st.Uuid.String()+".png", entries[0].Name()) } func TestCoverRunSearchesUntilStopped(t *testing.T) { apis := newCoverAPIs(t) apis.games["Game"] = 1 store := fakedb.New() store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) c := newCovers(t, store, apis.options(true)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { c.Run(ctx); close(done) }() assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond, "searches when started") store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack c.Trigger() assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 }, time.Second, 10*time.Millisecond, "searches again when triggered") cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("Run did not stop") } } func TestCoverRunWithoutKeyDoesNothing(t *testing.T) { c := newCovers(t, fakedb.New(), CoverOptions{}) assert.False(t, c.SearchesAutomatically()) done := make(chan struct{}) go func() { c.Run(context.Background()); close(done) }() select { case <-done: case <-time.After(time.Second): t.Fatal("Run should return at once without a SteamGridDB key") } } func TestCoverSearchNames(t *testing.T) { assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"}, coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"})) assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil)) assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil)) } func TestNormalizeTitle(t *testing.T) { assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie")) assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII")) } func TestBestGame(t *testing.T) { games := func(names ...string) []steamGridDBGame { result := make([]steamGridDBGame, len(names)) for i, name := range names { result[i] = steamGridDBGame{ID: i + 1, Name: name} } return result } tests := []struct { search string games []steamGridDBGame want string }{ // Real SteamGridDB results, in their order. {"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV", "Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"}, {"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"}, {"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"}, {"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"}, {"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"}, // No result has every word: SteamGridDB's first result. {"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"}, {"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"}, {"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"}, } for _, tt := range tests { assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search) } }