diff --git a/.env.test b/.env.test deleted file mode 100644 index e22b43f..0000000 --- a/.env.test +++ /dev/null @@ -1,19 +0,0 @@ -# Test Database Configuration -DB_HOST=localhost -DB_PORT=5433 -DB_USERNAME=testuser -DB_PASSWORD=testpass -DB_NAME=music_server_test - -# Test Paths -MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic -CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters - -# Server Configuration -PORT=8081 -LOG_LEVEL=debug -LOG_JSON=false - -# Session Authentication (for testing) -# Set to disable session auth during development -DISABLE_SESSION_AUTH=true diff --git a/cmd/web/search_test.go b/cmd/web/search_test.go new file mode 100644 index 0000000..0a3c428 --- /dev/null +++ b/cmd/web/search_test.go @@ -0,0 +1,100 @@ +package web + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "music-server/internal/backend" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var soundtracks = []string{ + "Call of Duty: Modern Warfare", + "Final Fantasy VII", + "Joe & Mac", + "Kingdom Hearts", + "Lemmings (SNES + Mega Drive)", + "MG", + "Metal Gear Solid 4 - Guns of the Patriots", + "Pokémon HeartGold & SoulSilver", + "Resident Evil 2 (Remake)", + "Ys III - Wanderers from Ys (+The Oath in Felghana)", +} + +func newCache(names []string) *backend.SearchCache { + return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil }) +} + +func searchData(t *testing.T) []backend.SoundtrackData { + data, err := newCache(soundtracks).Get(context.Background()) + require.NoError(t, err) + return data +} + +func TestSearch(t *testing.T) { + data := searchData(t) + tests := []struct { + query string + want []string + }{ + // Exact (case-insensitive) substring + {"resident", []string{"Resident Evil 2 (Remake)"}}, + // Ignoring spaces and special characters + {"joeandmac", []string{"Joe & Mac"}}, + {"snes+mega", []string{"Lemmings (SNES + Mega Drive)"}}, + {"pokemon", []string{"Pokémon HeartGold & SoulSilver"}}, + // Letters in order + {"codmw", []string{"Call of Duty: Modern Warfare"}}, + {"mgs4gotp", []string{"Metal Gear Solid 4 - Guns of the Patriots"}}, + {"re2r", []string{"Resident Evil 2 (Remake)"}}, + // No match + {"zzz", nil}, + // Regex special characters are plain text + {"(remake)", []string{"Resident Evil 2 (Remake)"}}, + {"[", nil}, + } + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + assert.Equal(t, tt.want, search(tt.query, data)) + }) + } +} + +func TestSearchEmptyReturnsEverything(t *testing.T) { + assert.Equal(t, soundtracks, search("", searchData(t))) +} + +func TestSearchOrdering(t *testing.T) { + // "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order. + results := search("mg", searchData(t)) + require.GreaterOrEqual(t, len(results), 2) + assert.Equal(t, "MG", results[0]) + assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots") +} + +func TestSearchHandler(t *testing.T) { + handler := NewSearchHandler(newCache(soundtracks)) + req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Kingdom Hearts") + assert.NotContains(t, rec.Body.String(), "Final Fantasy") +} + +func TestSearchHandlerLoadError(t *testing.T) { + cache := backend.NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") }) + rec := httptest.NewRecorder() + NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil)) + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} diff --git a/compose.test.yaml b/compose.test.yaml deleted file mode 100644 index 07d8e35..0000000 --- a/compose.test.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: '3.8' - -services: - test-db: - image: postgres:15-alpine - environment: - POSTGRES_USER: testuser - POSTGRES_PASSWORD: testpass - POSTGRES_DB: music_server_test - ports: - - "5433:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] - interval: 5s - timeout: 5s - retries: 5 - volumes: - - test-db-data:/var/lib/postgresql/data - -volumes: - test-db-data: diff --git a/internal/backend/characters_test.go b/internal/backend/characters_test.go new file mode 100644 index 0000000..e25a076 --- /dev/null +++ b/internal/backend/characters_test.go @@ -0,0 +1,58 @@ +package backend + +import ( + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newCharacterService(t *testing.T) *CharacterService { + t.Helper() + base := t.TempDir() + writeFile(t, filepath.Join(base, "secret.png"), "outside") + dir := filepath.Join(base, "characters") + writeFile(t, filepath.Join(dir, "Adol.png"), "adol") + writeFile(t, filepath.Join(dir, "Aloy.JPG"), "aloy") + writeFile(t, filepath.Join(dir, "notes.txt"), "text") + writeFile(t, filepath.Join(dir, "sub", "Hidden.png"), "hidden") + + c, err := NewCharacterService(dir) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + return c +} + +func TestCharacterList(t *testing.T) { + characters, err := newCharacterService(t).List() + require.NoError(t, err) + assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters) +} + +func TestCharacterOpen(t *testing.T) { + c := newCharacterService(t) + + f, err := c.Open("Adol.png") + require.NoError(t, err) + content, _ := io.ReadAll(f) + f.Close() + assert.Equal(t, "adol", string(content)) + + _, err = c.Open("Missing.png") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestCharacterOpenRejectsPathTraversal(t *testing.T) { + c := newCharacterService(t) + for _, name := range []string{"", "../secret.png", "sub/Hidden.png", "/etc/passwd", "..", "notes.txt"} { + _, err := c.Open(name) + assert.ErrorIs(t, err, ErrInvalidName, name) + } +} + +func TestNewCharacterServiceMissingDir(t *testing.T) { + _, err := NewCharacterService(filepath.Join(t.TempDir(), "missing")) + assert.Error(t, err) +} diff --git a/internal/backend/main_test.go b/internal/backend/main_test.go new file mode 100644 index 0000000..d4cb727 --- /dev/null +++ b/internal/backend/main_test.go @@ -0,0 +1,15 @@ +//go:build !integration + +package backend + +import ( + "os" + "testing" + + "music-server/internal/logging" +) + +func TestMain(m *testing.M) { + logging.Init("fatal", false) + os.Exit(m.Run()) +} diff --git a/internal/backend/music_test.go b/internal/backend/music_test.go new file mode 100644 index 0000000..1747db5 --- /dev/null +++ b/internal/backend/music_test.go @@ -0,0 +1,224 @@ +package backend + +import ( + "context" + "os" + "path/filepath" + "testing" + + "music-server/internal/db/repository" + "music-server/internal/testutil/fakedb" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +// addSoundtrackWithSongs creates a soundtrack folder with song files and matching database rows. +func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name string, timesPlayed int32, songs ...string) repository.Soundtrack { + t.Helper() + dir := musicDir + "/" + name + "/" + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: name, Path: dir, TimesPlayed: timesPlayed}) + for _, song := range songs { + fileName := song + ".mp3" + writeFile(t, dir+fileName, song) + store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: song, Path: dir + fileName, FileName: &fileName}) + } + return st +} + +func TestMusicServiceEmptyQueue(t *testing.T) { + m := NewMusicService(fakedb.New(), t.TempDir()) + + _, err := m.NextSong() + assert.ErrorIs(t, err, ErrQueueEmpty) + _, err = m.PreviousSong() + assert.ErrorIs(t, err, ErrQueueEmpty) + _, err = m.Song(0) + assert.ErrorIs(t, err, ErrQueueEmpty) + assert.Equal(t, SongInfo{}, m.SongInfo(context.Background())) + assert.Empty(t, m.PlayedSongs(context.Background())) + assert.NoError(t, m.AddLatestPlayed(context.Background())) + assert.NoError(t, m.SetPlayed(context.Background(), -1)) +} + +func TestMusicServiceNoSoundtracks(t *testing.T) { + m := NewMusicService(fakedb.New(), t.TempDir()) + ctx := context.Background() + + _, err := m.RandomSong(ctx) + assert.ErrorIs(t, err, ErrNoSongs) + _, err = m.RandomSongLowChance(ctx) + assert.ErrorIs(t, err, ErrNoSongs) + _, err = m.RandomSongClassic(ctx) + assert.ErrorIs(t, err, ErrNoSongs) +} + +func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) { + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"}) + m := NewMusicService(store, t.TempDir()) + + _, err := m.RandomSong(context.Background()) + assert.ErrorIs(t, err, ErrNoSongs) +} + +func TestMusicServiceQueue(t *testing.T) { + store := fakedb.New() + musicDir := t.TempDir() + addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2") + m := NewMusicService(store, t.TempDir()) + ctx := context.Background() + + first, err := m.RandomSong(ctx) + require.NoError(t, err) + m.AddLatestToQueue() + second, err := m.RandomSong(ctx) + require.NoError(t, err) + m.AddLatestToQueue() + + info := m.SongInfo(ctx) + assert.Equal(t, "Game A", info.Soundtrack) + assert.Equal(t, 1, info.SongNo) + assert.True(t, info.CurrentlyPlaying) + + path, err := m.PreviousSong() + require.NoError(t, err) + assert.Equal(t, first, path) + path, err = m.PreviousSong() + require.NoError(t, err) + assert.Equal(t, first, path, "previous stays on the first song") + + path, err = m.NextSong() + require.NoError(t, err) + assert.Equal(t, second, path) + path, err = m.NextSong() + require.NoError(t, err) + assert.Equal(t, second, path, "next stays on the last song") + + path, err = m.Song(99) + require.NoError(t, err) + assert.Equal(t, second, path, "song number is clamped") + + played := m.PlayedSongs(ctx) + require.Len(t, played, 2) + assert.False(t, played[0].CurrentlyPlaying) + assert.True(t, played[1].CurrentlyPlaying) + + require.NoError(t, m.Reset(ctx)) + _, err = m.NextSong() + assert.ErrorIs(t, err, ErrQueueEmpty) +} + +func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) { + m := NewMusicService(fakedb.New(), t.TempDir()) + m.AddLatestToQueue() + _, err := m.NextSong() + assert.ErrorIs(t, err, ErrQueueEmpty) +} + +func TestMusicServiceMarkPlayed(t *testing.T) { + store := fakedb.New() + st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") + m := NewMusicService(store, t.TempDir()) + ctx := context.Background() + + _, err := m.RandomSong(ctx) + require.NoError(t, err) + m.AddLatestToQueue() + + require.NoError(t, m.AddLatestPlayed(ctx)) + require.NoError(t, m.SetPlayed(ctx, 0)) + require.NoError(t, m.SetPlayed(ctx, 5), "out of range is ignored") + + updated, _ := store.Soundtrack("Game A") + assert.Equal(t, int32(2), updated.TimesPlayed) + assert.Equal(t, int32(2), store.SongsOf(st.ID)[0].TimesPlayed) +} + +func TestMusicServiceRemovesBrokenSongs(t *testing.T) { + store := fakedb.New() + musicDir := t.TempDir() + st := addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Good") + missing := "Missing.mp3" + store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing}) + wav := "Wave.wav" + writeFile(t, st.Path+wav, "wav") + store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav}) + m := NewMusicService(store, t.TempDir()) + + for range 20 { + path, err := m.RandomSong(context.Background()) + require.NoError(t, err) + assert.Equal(t, st.Path+"Good.mp3", path) + } + songs := store.SongsOf(st.ID) + require.Len(t, songs, 1) + assert.Equal(t, "Good", songs[0].SongName) +} + +func TestMusicServiceRandomSongClassic(t *testing.T) { + store := fakedb.New() + st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Good") + missing := "Missing.mp3" + store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing}) + m := NewMusicService(store, t.TempDir()) + + for range 20 { + path, err := m.RandomSongClassic(context.Background()) + require.NoError(t, err) + assert.Equal(t, st.Path+"Good.mp3", path) + } + assert.Len(t, store.SongsOf(st.ID), 1) +} + +func TestLowChanceWeights(t *testing.T) { + soundtracks := []repository.Soundtrack{ + {SoundtrackName: "Often", TimesPlayed: 30}, + {SoundtrackName: "Average", TimesPlayed: 20}, + {SoundtrackName: "Rarely", TimesPlayed: 10}, + } + weights := lowChanceWeights(soundtracks) + assert.Equal(t, []int{1, 1, 10}, []int{weights[0].weight, weights[1].weight, weights[2].weight}) +} + +func TestAveragePlayed(t *testing.T) { + assert.Equal(t, int32(0), averagePlayed(nil)) + assert.Equal(t, int32(20), averagePlayed([]repository.Soundtrack{{TimesPlayed: 10}, {TimesPlayed: 30}})) +} + +func TestSoundtrackNames(t *testing.T) { + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"}) + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"}) + m := NewMusicService(store, t.TempDir()) + + names, err := m.SoundtrackNames(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"A", "B"}, names) + + random, err := m.SoundtrackNamesRandom(context.Background()) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"A", "B"}, random) + assert.Equal(t, []string{"A", "B"}, names, "shuffling must not change the cached order") +} + +func TestSoundCheckSong(t *testing.T) { + dir := t.TempDir() + m := NewMusicService(fakedb.New(), dir) + _, err := m.SoundCheckSong() + assert.ErrorIs(t, err, ErrNoSongs) + + writeFile(t, filepath.Join(dir, "check.mp3"), "x") + path, err := m.SoundCheckSong() + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "check.mp3"), path) + + _, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong() + assert.Error(t, err) +} diff --git a/internal/backend/releases_test.go b/internal/backend/releases_test.go new file mode 100644 index 0000000..50a7c11 --- /dev/null +++ b/internal/backend/releases_test.go @@ -0,0 +1,81 @@ +package backend + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newGitea(t *testing.T, status int) (*httptest.Server, *atomic.Int32) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(status) + json.NewEncoder(w).Encode(giteaRelease{ + Id: 1, + Name: "v1.2.3", + Assets: []giteaAsset{ + {Name: "MusicPlayer.exe", DownloadUrl: "https://example.com/MusicPlayer.exe"}, + {Name: "MusicPlayer.x86_64", DownloadUrl: "https://example.com/MusicPlayer.x86_64"}, + }, + }) + })) + t.Cleanup(server.Close) + return server, &calls +} + +func TestReleaseService(t *testing.T) { + server, _ := newGitea(t, http.StatusOK) + r := NewReleaseService(server.URL) + ctx := context.Background() + + version, err := r.LatestVersion(ctx) + require.NoError(t, err) + assert.Equal(t, "v1.2.3", version) + + names, err := r.AssetNames(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"MusicPlayer.exe", "MusicPlayer.x86_64"}, names) + + url, err := r.AssetURL(ctx, ".exe") + require.NoError(t, err) + assert.Equal(t, "https://example.com/MusicPlayer.exe", url) + + _, err = r.AssetURL(ctx, ".dmg") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestReleaseServiceCachesRelease(t *testing.T) { + server, calls := newGitea(t, http.StatusOK) + r := NewReleaseService(server.URL) + now := time.Now() + r.now = func() time.Time { return now } + ctx := context.Background() + + for range 3 { + _, err := r.LatestVersion(ctx) + require.NoError(t, err) + } + assert.Equal(t, int32(1), calls.Load()) + + now = now.Add(releaseCacheTTL + time.Second) + _, err := r.LatestVersion(ctx) + require.NoError(t, err) + assert.Equal(t, int32(2), calls.Load()) +} + +func TestReleaseServiceUpstreamErrors(t *testing.T) { + server, _ := newGitea(t, http.StatusInternalServerError) + _, err := NewReleaseService(server.URL).LatestVersion(context.Background()) + assert.ErrorIs(t, err, ErrUpstream) + + _, err = NewReleaseService("http://127.0.0.1:1").AssetNames(context.Background()) + assert.ErrorIs(t, err, ErrUpstream) +} diff --git a/internal/backend/search_cache_test.go b/internal/backend/search_cache_test.go new file mode 100644 index 0000000..bd2c9af --- /dev/null +++ b/internal/backend/search_cache_test.go @@ -0,0 +1,67 @@ +package backend + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanSearchTerm(t *testing.T) { + tests := map[string]string{ + "MGS4": "mgs4", + " test ": "test", + "Test 123": "test123", + "A+B": "aplusb", + "Mario & Luigi": "marioandluigi", + "É": "e", + "It's": "it's", + "Rock 'n Roll": "rockandroll", + "": "", + } + for input, expected := range tests { + assert.Equal(t, expected, CleanSearchTerm(input), input) + } +} + +func TestMatchesSubsequence(t *testing.T) { + assert.True(t, MatchesSubsequence("", "anything")) + assert.True(t, MatchesSubsequence("ff7", "finalfantasy7")) + assert.True(t, MatchesSubsequence("lumière", "clairobscur-lumière")) + assert.False(t, MatchesSubsequence("7ff", "finalfantasy7")) + // Characters that are special in regular expressions are matched literally. + for _, term := range []string{"(", "[", "*", "\\", "a(b", ".*"} { + assert.NotPanics(t, func() { MatchesSubsequence(term, "plain text") }, term) + assert.False(t, MatchesSubsequence(term, "plain text"), term) + } + assert.True(t, MatchesSubsequence("(", "game (remake)")) +} + +func TestSearchCache(t *testing.T) { + names := []string{"Final Fantasy VII"} + calls := 0 + cache := NewSearchCache(func(context.Context) ([]string, error) { + calls++ + return names, nil + }) + ctx := context.Background() + + data, err := cache.Get(ctx) + require.NoError(t, err) + assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data) + _, _ = cache.Get(ctx) + assert.Equal(t, 1, calls, "data is cached") + + names = []string{"Chrono Trigger"} + require.NoError(t, cache.Reload(ctx)) + data, _ = cache.Get(ctx) + assert.Equal(t, "Chrono Trigger", data[0].Original) +} + +func TestSearchCacheLoadError(t *testing.T) { + cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") }) + _, err := cache.Get(context.Background()) + assert.Error(t, err) +} diff --git a/internal/backend/sync_integration_test.go b/internal/backend/sync_integration_test.go new file mode 100644 index 0000000..2c00d19 --- /dev/null +++ b/internal/backend/sync_integration_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package backend + +import ( + "context" + "os" + "path/filepath" + "testing" + + "music-server/internal/db/repository" + "music-server/internal/logging" + "music-server/internal/testutil/pgtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + logging.Init("fatal", false) + os.Exit(pgtest.Main(m)) +} + +// copyTestMusic copies the repository's testMusic folder so tests can change it. +func copyTestMusic(t *testing.T) string { + t.Helper() + dst := t.TempDir() + require.NoError(t, os.CopyFS(dst, os.DirFS(filepath.Join("..", "..", "testMusic")))) + return dst +} + +// TestSyncWithPostgres runs syncs against a real database to check the SQL queries. +func TestSyncWithPostgres(t *testing.T) { + ctx := context.Background() + q := repository.New(pgtest.NewMigratedDatabase(t).Pool) + musicDir := copyTestMusic(t) + s := NewSyncService(q, musicDir, nil) + run := func(full bool) SyncResponse { + require.True(t, s.Start(full)) + s.Wait() + result := s.Result() + require.Empty(t, result.CatchedErrors) + return result + } + + result := run(false) + assert.Equal(t, []string{"A Bird Story", "Clair Obscur - Expedition 33", "Metal Gear Rising - Revengeance"}, result.SoundtracksAdded) + + soundtracks, err := q.FindAllSoundtracks(ctx) + require.NoError(t, err) + require.Len(t, soundtracks, 3) + bird := soundtracks[0] + assert.NotEmpty(t, bird.Fingerprint) + songs, err := q.FindSongsFromSoundtrack(ctx, bird.ID) + require.NoError(t, err) + assert.Len(t, songs, 6) + + // Play a song, then rename one of its files and, in a later sync, the soundtrack folder. + // (Renaming both at once changes the content hash, so the folder would count as new.) + require.NoError(t, q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: bird.ID, SongName: "2. Yesterday"})) + birdDir := filepath.Join(musicDir, "A Bird Story") + require.NoError(t, os.Rename(filepath.Join(birdDir, "2. Yesterday.mp3"), filepath.Join(birdDir, "02 Yesterday.mp3"))) + + result = run(false) + assert.Equal(t, []string{"A Bird Story"}, result.SoundtracksChangedContent) + + require.NoError(t, os.Rename(birdDir, filepath.Join(musicDir, "A Bird Story (2014)"))) + result = run(false) + assert.Equal(t, map[string]string{"A Bird Story": "A Bird Story (2014)"}, result.SoundtracksChangedTitle) + assert.Empty(t, result.SoundtracksAdded) + assert.Empty(t, result.SoundtracksRemoved) + + renamed, err := q.GetSoundtrackById(ctx, bird.ID) + require.NoError(t, err) + assert.Equal(t, "A Bird Story (2014)", renamed.SoundtrackName) + songs, err = q.FindSongsFromSoundtrack(ctx, bird.ID) + require.NoError(t, err) + require.Len(t, songs, 6) + var yesterday repository.Song + for _, song := range songs { + assert.Contains(t, song.Path, "/A Bird Story (2014)/") + if song.SongName == "02 Yesterday" { + yesterday = song + } + } + assert.Equal(t, int32(1), yesterday.TimesPlayed, "play count survives renames") + + // Nothing changed: the fingerprint lets the sync skip everything. + result = run(false) + assert.Empty(t, result.SoundtracksChangedContent) + assert.Empty(t, result.SoundtracksChangedTitle) + + // Remove a soundtrack and bring it back. + mgr := filepath.Join(musicDir, "Metal Gear Rising - Revengeance") + backup := filepath.Join(t.TempDir(), "mgr") + require.NoError(t, os.Rename(mgr, backup)) + result = run(false) + assert.Equal(t, []string{"Metal Gear Rising - Revengeance"}, result.SoundtracksRemoved) + active, err := q.FindAllSoundtracks(ctx) + require.NoError(t, err) + assert.Len(t, active, 2) + + require.NoError(t, os.Rename(backup, mgr)) + result = run(true) + assert.Equal(t, []string{"Metal Gear Rising - Revengeance"}, result.SoundtracksReAdded) + + // Classic random picks only songs of active soundtracks. + row, err := q.GetRandomActiveSong(ctx) + require.NoError(t, err) + assert.NotEmpty(t, row.SoundtrackPath) +} diff --git a/internal/backend/sync_test.go b/internal/backend/sync_test.go new file mode 100644 index 0000000..d73608f --- /dev/null +++ b/internal/backend/sync_test.go @@ -0,0 +1,398 @@ +package backend + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "music-server/internal/db/repository" + "music-server/internal/testutil/fakedb" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +func TestReconcileSongs(t *testing.T) { + keep := repository.Song{Uuid: uuid.New(), Path: "/m/a.mp3", SongName: "a", FileName: strPtr("a.mp3"), Hash: "ha"} + changed := repository.Song{Uuid: uuid.New(), Path: "/m/b.mp3", SongName: "b", FileName: strPtr("b.mp3"), Hash: "old"} + renamed := repository.Song{Uuid: uuid.New(), Path: "/m/c.mp3", SongName: "c", FileName: strPtr("c.mp3"), Hash: "hc"} + removed := repository.Song{Uuid: uuid.New(), Path: "/m/d.mp3", SongName: "d", FileName: strPtr("d.mp3"), Hash: "hd"} + + files := []songFile{ + {name: "a", fileName: "a.mp3", path: "/m/a.mp3", hash: "ha"}, + {name: "b", fileName: "b.mp3", path: "/m/b.mp3", hash: "new"}, + {name: "c2", fileName: "c2.mp3", path: "/m/c2.mp3", hash: "hc"}, + {name: "e", fileName: "e.mp3", path: "/m/e.mp3", hash: "he"}, + } + + ops := reconcileSongs([]repository.Song{keep, changed, renamed, removed}, files) + + assert.Equal(t, []songFile{files[3]}, ops.insert) + assert.Equal(t, []uuid.UUID{removed.Uuid}, ops.delete) + assert.ElementsMatch(t, []songUpdate{ + {uuid: changed.Uuid, file: files[1]}, + {uuid: renamed.Uuid, file: files[2]}, + }, ops.update) +} + +func TestReconcileSongsDuplicateHashes(t *testing.T) { + // Two identical files: each existing song may only be matched once. + existing := repository.Song{Uuid: uuid.New(), Path: "/m/a.mp3", SongName: "a", FileName: strPtr("a.mp3"), Hash: "h"} + files := []songFile{ + {name: "a", fileName: "a.mp3", path: "/m/a.mp3", hash: "h"}, + {name: "copy", fileName: "copy.mp3", path: "/m/copy.mp3", hash: "h"}, + } + ops := reconcileSongs([]repository.Song{existing}, files) + assert.Empty(t, ops.update) + assert.Empty(t, ops.delete) + assert.Equal(t, []songFile{files[1]}, ops.insert) +} + +func TestClassify(t *testing.T) { + existing := []repository.Soundtrack{ + {ID: 1, SoundtrackName: "Game A", Hash: "ha"}, + {ID: 2, SoundtrackName: "Game B", Hash: "hb"}, + } + tests := []struct { + name, hash string + status SoundtrackStatus + id int32 + }{ + {"Game A", "ha", NotChanged, 1}, + {"Game A", "changed", SoundtrackChanged, 1}, + {"Game B renamed", "hb", TitleChanged, 2}, + {"Game C", "hc", NewSoundtrack, 0}, + } + for _, tt := range tests { + status, match := classify(existing, tt.name, tt.hash) + assert.Equal(t, tt.status, status, tt.name) + if tt.id == 0 { + assert.Nil(t, match) + } else { + assert.Equal(t, tt.id, match.ID) + } + } +} + +func TestFingerprint(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.mp3"), "one") + writeFile(t, filepath.Join(dir, "sub", "b.mp3"), "two") + + first, err := fingerprint(dir) + require.NoError(t, err) + again, err := fingerprint(dir) + require.NoError(t, err) + assert.Equal(t, first, again) + + writeFile(t, filepath.Join(dir, "sub", "b.mp3"), "changed size") + changed, err := fingerprint(dir) + require.NoError(t, err) + assert.NotEqual(t, first, changed, "changes in sub folders are detected") + + // Moving the folder keeps the fingerprint, so renames are detected by name lookup failing. + moved := filepath.Join(t.TempDir(), "moved") + require.NoError(t, os.Rename(dir, moved)) + afterMove, err := fingerprint(moved) + require.NoError(t, err) + assert.Equal(t, changed, afterMove) +} + +func TestIsSong(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"a.mp3", "b.MP3", "c.wav", "cover.jpg"} { + writeFile(t, filepath.Join(dir, name), "x") + } + require.NoError(t, os.Mkdir(filepath.Join(dir, "folder.mp3"), 0o755)) + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + var songs []string + for _, e := range entries { + if isSong(e) { + songs = append(songs, e.Name()) + } + } + assert.Equal(t, []string{"a.mp3", "b.MP3"}, songs) + assert.Equal(t, "b", songName("b.MP3")) +} + +// syncFixture is a music folder with a SyncService backed by an in-memory database. +type syncFixture struct { + t *testing.T + dir string + store *fakedb.Store + sync *SyncService +} + +func newSyncFixture(t *testing.T) *syncFixture { + dir := t.TempDir() + store := fakedb.New() + return &syncFixture{t: t, dir: dir, store: store, sync: NewSyncService(store, dir, nil)} +} + +func (f *syncFixture) write(rel, content string) { + writeFile(f.t, filepath.Join(f.dir, rel), content) +} + +func (f *syncFixture) run(full bool) SyncResponse { + f.t.Helper() + require.True(f.t, f.sync.Start(full)) + f.sync.Wait() + require.False(f.t, f.sync.IsSyncing()) + return f.sync.Result() +} + +func (f *syncFixture) soundtrack(name string) repository.Soundtrack { + f.t.Helper() + st, ok := f.store.Soundtrack(name) + require.True(f.t, ok, "soundtrack %q not found", name) + return st +} + +func TestSyncAddsSoundtracksAndSongs(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01 Title.mp3", "a1") + f.write("Game A/02 Battle.MP3", "a2") + f.write("Game A/cover.jpg", "img") + f.write("Game B/01 Intro.mp3", "b1") + f.write("old/ignored.mp3", "x") + f.write("loose file.mp3", "x") + + result := f.run(false) + assert.Equal(t, []string{"Game A", "Game B"}, result.SoundtracksAdded) + assert.Empty(t, result.CatchedErrors) + + a := f.soundtrack("Game A") + assert.Equal(t, f.dir+"/Game A/", a.Path) + assert.NotEmpty(t, a.Hash) + assert.NotEmpty(t, a.Fingerprint) + songs := f.store.SongsOf(a.ID) + require.Len(t, songs, 2) + assert.Equal(t, "01 Title", songs[0].SongName) + assert.Equal(t, "02 Battle", songs[1].SongName) + assert.Equal(t, a.Uuid, songs[0].SoundtrackUuid) + assert.NotEmpty(t, songs[0].Hash) + + _, ok := f.store.Soundtrack("old") + assert.False(t, ok, "skipped folders are not synced") +} + +func TestSyncSkipsUnchangedSoundtracksByFingerprint(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.run(false) + + // If the folder were hashed again, the stored hash would be replaced. + a := f.soundtrack("Game A") + require.NoError(t, f.store.UpdateSoundtrackHash(context.Background(), repository.UpdateSoundtrackHashParams{Hash: "stale", ID: a.ID})) + + result := f.run(false) + assert.Empty(t, result.SoundtracksAdded) + assert.Empty(t, result.SoundtracksChangedContent) + assert.Equal(t, "stale", f.soundtrack("Game A").Hash) + + result = f.run(true) + assert.NotEqual(t, "stale", f.soundtrack("Game A").Hash, "a full sync always hashes") + assert.Empty(t, result.SoundtracksChangedTitle, "a full sync does not report unchanged titles") +} + +func TestSyncDetectsChangedContent(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.write("Game A/02.mp3", "a2") + f.run(false) + a := f.soundtrack("Game A") + before := f.store.SongsOf(a.ID) + + require.NoError(t, os.Remove(filepath.Join(f.dir, "Game A/02.mp3"))) + require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A/01.mp3"), filepath.Join(f.dir, "Game A/01 renamed.mp3"))) + f.write("Game A/03.mp3", "a3") + + result := f.run(false) + assert.Equal(t, []string{"Game A"}, result.SoundtracksChangedContent) + + after := f.store.SongsOf(a.ID) + require.Len(t, after, 2) + assert.Equal(t, "01 renamed", after[0].SongName) + assert.Equal(t, before[0].Uuid, after[0].Uuid, "renamed song keeps its identity and play count") + assert.Equal(t, "03", after[1].SongName) +} + +func TestSyncDetectsRenamedSoundtrack(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.run(false) + a := f.soundtrack("Game A") + require.NoError(t, f.store.AddSongPlayed(context.Background(), repository.AddSongPlayedParams{SoundtrackID: a.ID, SongName: "01"})) + + require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A"), filepath.Join(f.dir, "Game A Remastered"))) + result := f.run(false) + + assert.Equal(t, map[string]string{"Game A": "Game A Remastered"}, result.SoundtracksChangedTitle) + assert.Empty(t, result.SoundtracksRemoved) + assert.Empty(t, result.SoundtracksAdded) + + renamed := f.soundtrack("Game A Remastered") + assert.Equal(t, a.ID, renamed.ID) + assert.Equal(t, f.dir+"/Game A Remastered/", renamed.Path) + songs := f.store.SongsOf(a.ID) + require.Len(t, songs, 1) + assert.Equal(t, f.dir+"/Game A Remastered/01.mp3", songs[0].Path) + assert.Equal(t, int32(1), songs[0].TimesPlayed) +} + +func TestSyncRemovedAndReAdded(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.write("Game B/01.mp3", "b1") + f.run(false) + + backup := filepath.Join(t.TempDir(), "Game B") + require.NoError(t, os.Rename(filepath.Join(f.dir, "Game B"), backup)) + result := f.run(false) + assert.Equal(t, []string{"Game B"}, result.SoundtracksRemoved) + assert.NotNil(t, f.soundtrack("Game B").Deleted) + + // Polling the result must not change it. + assert.Equal(t, result, f.sync.Result()) + assert.Equal(t, result, f.sync.Result()) + + require.NoError(t, os.Rename(backup, filepath.Join(f.dir, "Game B"))) + result = f.run(false) + assert.Equal(t, []string{"Game B"}, result.SoundtracksReAdded) + assert.Empty(t, result.SoundtracksRemoved) + assert.Nil(t, f.soundtrack("Game B").Deleted) + + // A full sync reports re-added soundtracks too. + require.NoError(t, os.Rename(filepath.Join(f.dir, "Game B"), backup)) + f.run(false) + require.NoError(t, os.Rename(backup, filepath.Join(f.dir, "Game B"))) + result = f.run(true) + assert.Equal(t, []string{"Game B"}, result.SoundtracksReAdded) + assert.Nil(t, f.soundtrack("Game B").Deleted) +} + +func TestSyncUpdatesMovedMusicPath(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.run(false) + + newDir := filepath.Join(t.TempDir(), "music") + require.NoError(t, os.Rename(f.dir, newDir)) + f.dir = newDir + f.sync = NewSyncService(f.store, newDir, nil) + f.run(false) + + a := f.soundtrack("Game A") + assert.Equal(t, newDir+"/Game A/", a.Path) + assert.Equal(t, newDir+"/Game A/01.mp3", f.store.SongsOf(a.ID)[0].Path) +} + +func TestSyncIDFileCleanup(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.write("Game A/.5.id", "") + f.write("Game B/01.mp3", "b1") + f.write("Game B/.6.id", "") + + // Simulate an upgraded database: hashes are known but no fingerprints yet. + for _, name := range []string{"Game A", "Game B"} { + hash, err := dirHash(filepath.Join(f.dir, name) + "/") + require.NoError(t, err) + f.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: name, Path: f.dir + "/" + name + "/", Hash: hash}) + } + f.write("Game B/02.mp3", "b2") // Game B changes, Game A does not + + f.run(false) + assert.FileExists(t, filepath.Join(f.dir, "Game A/.5.id"), "unchanged soundtracks keep their .id files") + assert.NoFileExists(t, filepath.Join(f.dir, "Game B/.6.id"), "changed soundtracks are cleaned up") + + result := f.run(true) + assert.NoFileExists(t, filepath.Join(f.dir, "Game A/.5.id"), "a full sync cleans up everything") + assert.Empty(t, result.CatchedErrors) + + // The stored fingerprint matches the folder without .id files, so the next sync skips it. + a := f.soundtrack("Game A") + fp, err := fingerprint(a.Path) + require.NoError(t, err) + assert.Equal(t, fp, a.Fingerprint) +} + +func TestSyncFullRemovesBrokenSongs(t *testing.T) { + f := newSyncFixture(t) + f.write("Game A/01.mp3", "a1") + f.run(false) + gone := f.store.AddTestSong(repository.Song{SoundtrackID: 999, SongName: "gone", Path: "/does/not/exist.mp3"}) + + f.run(false) + assert.Len(t, f.store.Songs, 2, "changes-only sync leaves other songs alone") + + f.run(true) + for _, song := range f.store.Songs { + assert.NotEqual(t, gone.Uuid, song.Uuid) + } +} + +func TestSyncUnreadableMusicPath(t *testing.T) { + store := fakedb.New() + existing := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game A"}) + s := NewSyncService(store, filepath.Join(t.TempDir(), "missing"), nil) + + require.True(t, s.Start(false)) + s.Wait() + result := s.Result() + require.Len(t, result.CatchedErrors, 1) + assert.Contains(t, result.CatchedErrors[0], "ReadDir") + st, _ := store.Soundtrack(existing.SoundtrackName) + assert.Nil(t, st.Deleted, "nothing is marked deleted when the music path cannot be read") +} + +func TestSyncStartOnlyOnce(t *testing.T) { + release := make(chan struct{}) + s := NewSyncService(fakedb.New(), t.TempDir(), func(context.Context) { <-release }) + + require.True(t, s.Start(false)) + assert.True(t, s.IsSyncing()) + assert.False(t, s.Start(true), "a second sync cannot start while one is running") + + close(release) + s.Wait() + assert.False(t, s.IsSyncing()) + assert.True(t, s.Start(false)) + s.Wait() +} + +func TestSyncProgressBeforeFirstSync(t *testing.T) { + s := NewSyncService(fakedb.New(), t.TempDir(), nil) + assert.Equal(t, "0", s.Progress().Progress) + assert.Equal(t, SyncResponse{}, s.Result()) +} + +func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) { + var syncingDuringCallback bool + var s *SyncService + s = NewSyncService(fakedb.New(), t.TempDir(), func(context.Context) { syncingDuringCallback = s.IsSyncing() }) + require.True(t, s.Start(false)) + s.Wait() + assert.True(t, syncingDuringCallback, "caches are refreshed before requests are let through again") +} + +func TestResetDB(t *testing.T) { + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"}) + store.AddTestSong(repository.Song{SongName: "a"}) + s := NewSyncService(store, t.TempDir(), nil) + require.NoError(t, s.ResetDB(context.Background())) + assert.Empty(t, store.Soundtracks) + assert.Empty(t, store.Songs) +} + +func TestFormatDuration(t *testing.T) { + assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second)) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4098642 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func env(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +func validEnv() map[string]string { + return map[string]string{ + "DB_HOST": "localhost", + "DB_PORT": "5432", + "DB_USERNAME": "user", + "DB_PASSWORD": "pass", + "DB_NAME": "music", + "MUSIC_PATH": "/music", + "CHARACTERS_PATH": "/characters", + } +} + +func TestLoadDefaults(t *testing.T) { + cfg, err := load(env(validEnv())) + require.NoError(t, err) + assert.Equal(t, 8080, cfg.Port) + assert.Equal(t, "info", cfg.LogLevel) + assert.Equal(t, "songs", cfg.SoundCheckPath) + assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL) + assert.Empty(t, cfg.TrustedProxies) +} + +func TestLoadMissingRequired(t *testing.T) { + values := validEnv() + delete(values, "MUSIC_PATH") + delete(values, "DB_HOST") + _, err := load(env(values)) + require.Error(t, err) + assert.Contains(t, err.Error(), "MUSIC_PATH is required") + assert.Contains(t, err.Error(), "DB_HOST is required") +} + +func TestLoadTrustedProxies(t *testing.T) { + values := validEnv() + values["TRUSTED_PROXIES"] = "10.0.0.0/8, 172.16.0.0/12" + cfg, err := load(env(values)) + require.NoError(t, err) + require.Len(t, cfg.TrustedProxies, 2) + assert.Equal(t, "10.0.0.0/8", cfg.TrustedProxies[0].String()) + + values["TRUSTED_PROXIES"] = "not-a-cidr" + _, err = load(env(values)) + assert.ErrorContains(t, err, "TRUSTED_PROXIES") +} + +func TestLoadInvalidPort(t *testing.T) { + values := validEnv() + values["PORT"] = "abc" + _, err := load(env(values)) + assert.ErrorContains(t, err, "PORT") +} diff --git a/internal/db/main_integration_test.go b/internal/db/main_integration_test.go new file mode 100644 index 0000000..43d8abf --- /dev/null +++ b/internal/db/main_integration_test.go @@ -0,0 +1,16 @@ +//go:build integration + +package db_test + +import ( + "os" + "testing" + + "music-server/internal/logging" + "music-server/internal/testutil/pgtest" +) + +func TestMain(m *testing.M) { + logging.Init("error", false) + os.Exit(pgtest.Main(m)) +} diff --git a/internal/db/migration_integration_test.go b/internal/db/migration_integration_test.go new file mode 100644 index 0000000..5fdfa25 --- /dev/null +++ b/internal/db/migration_integration_test.go @@ -0,0 +1,86 @@ +//go:build integration + +package db_test + +import ( + "context" + "fmt" + "testing" + + "music-server/internal/db" + "music-server/internal/testutil/pgtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func openDatabase(t *testing.T, dbname string) *db.Database { + t.Helper() + database, err := db.NewDatabase(context.Background(), pgtest.ConnString(dbname)) + require.NoError(t, err) + t.Cleanup(database.Close) + return database +} + +func count(t *testing.T, database *db.Database, query string) int { + t.Helper() + var n int + require.NoError(t, database.Pool.QueryRow(context.Background(), query).Scan(&n)) + return n +} + +// TestMigrationsKeepData applies the migrations step by step with data in between, +// like a database that was created by an older version. +func TestMigrationsKeepData(t *testing.T) { + ctx := context.Background() + database := openDatabase(t, pgtest.NewDatabase(t)) + m, closeFn, err := database.NewMigrator() + require.NoError(t, err) + defer closeFn() + + require.NoError(t, m.Steps(4)) + + for i := 1; i <= 5; i++ { + _, err := database.Pool.Exec(ctx, `INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, NOW())`, + fmt.Sprintf("Manual Game %d", i), fmt.Sprintf("/music/game%d/", i), fmt.Sprintf("hash-%d", i)) + require.NoError(t, err) + } + for i, game := range []int{1, 1, 2, 3, 4, 4, 4, 5, 5} { + _, err := database.Pool.Exec(ctx, `INSERT INTO song (game_id, song_name, path, hash) VALUES ($1, $2, $3, $4)`, + game, fmt.Sprintf("Song %d", i), fmt.Sprintf("/music/game%d/%d.mp3", game, i), fmt.Sprintf("song-hash-%d", i)) + require.NoError(t, err) + } + + require.NoError(t, m.Up()) + version, dirty, err := m.Version() + require.NoError(t, err) + assert.False(t, dirty) + assert.Equal(t, uint(6), version) + + assert.Equal(t, 5, count(t, database, "SELECT COUNT(*) FROM soundtrack")) + assert.Equal(t, 9, count(t, database, "SELECT COUNT(*) FROM song")) + assert.Equal(t, 0, count(t, database, "SELECT COUNT(*) FROM song WHERE uuid IS NULL OR soundtrack_uuid IS NULL")) + assert.Equal(t, 5, count(t, database, "SELECT COUNT(*) FROM soundtrack WHERE fingerprint = ''")) + _, err = database.Pool.Exec(ctx, "SELECT 1 FROM game") + assert.Error(t, err, "game table is renamed") +} + +func TestFingerprintMigrationDown(t *testing.T) { + database := openDatabase(t, pgtest.NewDatabase(t)) + m, closeFn, err := database.NewMigrator() + require.NoError(t, err) + defer closeFn() + + require.NoError(t, m.Up()) + require.NoError(t, m.Steps(-1)) + assert.Equal(t, 0, count(t, database, + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'")) + require.NoError(t, m.Up()) +} + +func TestRunMigrationsIsIdempotent(t *testing.T) { + database := openDatabase(t, pgtest.NewDatabase(t)) + require.NoError(t, database.RunMigrations()) + require.NoError(t, database.RunMigrations()) + assert.Equal(t, "up", database.Health(context.Background())["status"]) +} diff --git a/internal/db/sql_dump_integration_test.go b/internal/db/sql_dump_integration_test.go new file mode 100644 index 0000000..b05bfb6 --- /dev/null +++ b/internal/db/sql_dump_integration_test.go @@ -0,0 +1,39 @@ +//go:build integration + +package db_test + +import ( + "os" + "path/filepath" + "testing" + + "music-server/internal/testutil/pgtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSQLDumps migrates real database dumps (music_*_dump.sql in the repository root, not committed). +func TestSQLDumps(t *testing.T) { + for _, dump := range []string{"music_ref_dump.sql", "music_test_dump.sql", "music_prod_dump.sql"} { + t.Run(dump, func(t *testing.T) { + path := filepath.Join("..", "..", dump) + if _, err := os.Stat(path); err != nil { + t.Skipf("%s not found", dump) + } + + pgtest.CreateRole(t, "sansan") + dbname := pgtest.NewDatabase(t) + pgtest.LoadSQLFile(t, dbname, path) + database := openDatabase(t, dbname) + soundtracksBefore := count(t, database, "SELECT COUNT(*) FROM game") + songsBefore := count(t, database, "SELECT COUNT(*) FROM song") + + require.NoError(t, database.RunMigrations()) + + assert.Equal(t, soundtracksBefore, count(t, database, "SELECT COUNT(*) FROM soundtrack")) + assert.Equal(t, songsBefore, count(t, database, "SELECT COUNT(*) FROM song")) + assert.Equal(t, 0, count(t, database, "SELECT COUNT(*) FROM song WHERE uuid IS NULL")) + }) + } +} diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go new file mode 100644 index 0000000..7ddb187 --- /dev/null +++ b/internal/server/handlers_test.go @@ -0,0 +1,283 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "music-server/internal/backend" + "music-server/internal/config" + "music-server/internal/db/repository" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMusicRandomAndQueue(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "0123456789") + + rec := e.do(http.MethodGet, "/music/rand") + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) + assert.Equal(t, "0123456789", rec.Body.String()) + + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addPlayed").Code) + + var info backend.SongInfo + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info)) + assert.Equal(t, "Game A", info.Soundtrack) + assert.Equal(t, "Song", info.Song) + + for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/api/v1/music?song=0"} { + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) + } + assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code) + assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code) +} + +func TestMusicRangeRequest(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "0123456789") + + rec := e.do(http.MethodGet, "/music/rand", "Range", "bytes=2-5") + assert.Equal(t, http.StatusPartialContent, rec.Code) + assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range")) + assert.Equal(t, "2345", rec.Body.String()) + assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) +} + +func TestMusicEmptyStates(t *testing.T) { + e := newTestEnv(t) + for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} { + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) + } + assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code) + assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code) + + rec := e.do(http.MethodGet, "/music/list") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "null\n", rec.Body.String()) +} + +func TestMusicSoundCheck(t *testing.T) { + e := newTestEnv(t) + rec := e.do(http.MethodGet, "/music/soundTest") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "sound check", rec.Body.String()) +} + +func TestMusicSoundtrackLists(t *testing.T) { + e := newTestEnv(t) + e.addSong("B Game", "Song", "b") + e.addSong("A Game", "Song", "a") + + var ordered []string + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all/order").Body.Bytes(), &ordered)) + assert.Equal(t, []string{"A Game", "B Game"}, ordered) + + var random []string + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all").Body.Bytes(), &random)) + assert.ElementsMatch(t, ordered, random) +} + +func TestMusicDatabaseError(t *testing.T) { + e := newTestEnv(t) + e.store.Err = errors.New("db down") + assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/rand").Code) + assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/all/order").Code) +} + +func TestCharacterEndpoints(t *testing.T) { + e := newTestEnv(t) + + var list []string + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/characters").Body.Bytes(), &list)) + assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) + assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) + + rec := e.do(http.MethodGet, "/character?name=Adol.png") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) + + rec = e.do(http.MethodGet, "/api/v1/character?name=Aloy.jpg") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) + + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/character?name=Missing.png").Code) +} + +func TestCharacterPathTraversal(t *testing.T) { + e := newTestEnv(t) + for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} { + rec := e.do(http.MethodGet, "/character?name="+name) + assert.Equal(t, http.StatusBadRequest, rec.Code, name) + assert.NotContains(t, rec.Body.String(), "secret", name) + } +} + +func newGitea(t *testing.T, status int) string { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write([]byte(`{"id":1,"name":"v2.0.0","assets":[{"name":"Player.exe","browser_download_url":"https://example.com/Player.exe"}]}`)) + })) + t.Cleanup(server.Close) + return server.URL +} + +func TestDownloadEndpoints(t *testing.T) { + url := newGitea(t, http.StatusOK) + e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url }) + + rec := e.do(http.MethodGet, "/download") + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `"v2.0.0"`, rec.Body.String()) + + rec = e.do(http.MethodGet, "/api/v1/download/list") + assert.JSONEq(t, `["Player.exe"]`, rec.Body.String()) + + rec = e.do(http.MethodGet, "/download/windows") + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location")) + + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/download/linux").Code) +} + +func TestDownloadUpstreamDown(t *testing.T) { + e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) }) + for _, path := range []string{"/download", "/download/list", "/download/windows"} { + assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path) + } +} + +func withAuth(password string) func(*config.Config) { + return func(c *config.Config) { + c.DisableSessionAuth = false + c.APIPassword = password + } +} + +func (e *testEnv) createToken(password string) string { + e.t.Helper() + rec := e.doBody(http.MethodPost, "/api/v1/token", `{"password":"`+password+`"}`) + require.Equal(e.t, http.StatusOK, rec.Code, rec.Body.String()) + var resp TokenResponse + require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(e.t, "web", resp.ClientType) + assert.WithinDuration(e.t, time.Now().Add(tokenLifetime), resp.ExpiresAt, time.Minute) + return resp.Token +} + +func TestTokenAuth(t *testing.T) { + e := newTestEnv(t, withAuth("secret")) + + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version").Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Basic abc").Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer unknown").Code) + assert.Equal(t, http.StatusUnauthorized, e.doBody(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`).Code) + assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPost, "/api/v1/token", `{not json`).Code) + + token := e.createToken("secret") + auth := []string{"Authorization", "Bearer " + token} + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/version").Code, "legacy routes need no token") + + assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, "/api/v1/token").Code) +} + +func TestTokenOpenAccessWithoutPassword(t *testing.T) { + e := newTestEnv(t, withAuth("")) + token := e.createToken("anything") + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer "+token).Code) +} + +func TestTokenCleanupRequiresAuth(t *testing.T) { + e := newTestEnv(t, withAuth("secret")) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodPost, "/api/v1/token/cleanup").Code) + + token := e.createToken("secret") + e.store.Sessions["old"] = repository.Session{Token: "old", ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}} + assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/token/cleanup", "Authorization", "Bearer "+token).Code) + assert.False(t, e.store.HasSession("old")) + assert.True(t, e.store.HasSession(token)) +} + +func TestExpiredTokenIsDeleted(t *testing.T) { + e := newTestEnv(t, withAuth("secret")) + _, err := e.store.CreateSession(context.Background(), repository.CreateSessionParams{ + Token: "expired", + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Minute), Valid: true}, + }) + require.NoError(t, err) + + rec := e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer expired") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "Token expired") + // The fake refuses to delete with a cancelled context, like a real database would. + assert.Eventually(t, func() bool { return !e.store.HasSession("expired") }, time.Second, 10*time.Millisecond) +} + +func TestTokenRateLimit(t *testing.T) { + e := newTestEnv(t) + for range 10 { + assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`).Code) + } + rec := e.doBody(http.MethodPost, "/api/v1/token", `{}`) + assert.Equal(t, http.StatusTooManyRequests, rec.Code) + assert.Contains(t, rec.Body.String(), "Rate limit exceeded") +} + +// statsStore returns canned statistics. +type statsStore struct { + repository.Querier + err error +} + +func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { + return []repository.GetMostPlayedSoundtracksWithSongsRow{{ + SoundtrackID: int32(limit), + SoundtrackName: "Game A", + Songs: []byte(`[{"song_name":"Song","times_played":3}]`), + }}, s.err +} + +func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) { + return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err +} + +func TestStatisticsEndpoints(t *testing.T) { + e := newTestEnv(t) + e.server.stats = backend.NewStatisticsService(statsStore{}) + e.handler = e.server.RegisterRoutes() + + var soundtracks []backend.SoundtrackWithSongs + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played").Body.Bytes(), &soundtracks)) + require.Len(t, soundtracks, 1) + assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit") + assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) + + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit=500").Body.Bytes(), &soundtracks)) + assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped") + + for _, limit := range []string{"0", "-1", "abc"} { + assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit) + } + + var summary backend.StatisticsSummary + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary)) + assert.Equal(t, backend.StatisticsSummary{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, summary) + + e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")}) + e.handler = e.server.RegisterRoutes() + assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/summary").Code) +} diff --git a/internal/server/helpers_test.go b/internal/server/helpers_test.go new file mode 100644 index 0000000..0a77e95 --- /dev/null +++ b/internal/server/helpers_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "music-server/internal/config" + "music-server/internal/db/repository" + "music-server/internal/logging" + "music-server/internal/testutil/fakedb" + + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + logging.Init("fatal", false) + os.Exit(m.Run()) +} + +type fakeDatabase struct { + closed atomic.Bool + onClose func() +} + +func (f *fakeDatabase) Health(context.Context) map[string]string { + return map[string]string{"status": "up"} +} + +func (f *fakeDatabase) Close() { + if f.onClose != nil { + f.onClose() + } + f.closed.Store(true) +} + +// testEnv is a Server backed by an in-memory database and temporary folders. +type testEnv struct { + t *testing.T + cfg config.Config + store *fakedb.Store + db *fakeDatabase + server *Server + handler http.Handler +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func newTestEnv(t *testing.T, configure ...func(*config.Config)) *testEnv { + t.Helper() + base := t.TempDir() + cfg := config.Config{ + Port: 0, + MusicPath: filepath.Join(base, "music"), + CharactersPath: filepath.Join(base, "characters"), + SoundCheckPath: filepath.Join(base, "songs"), + FrontendPath: filepath.Join(base, "frontend"), + DisableSessionAuth: true, + ReleasesURL: "http://127.0.0.1:1", + } + for _, c := range configure { + c(&cfg) + } + writeFile(t, filepath.Join(cfg.CharactersPath, "Adol.png"), "png") + writeFile(t, filepath.Join(cfg.CharactersPath, "Aloy.jpg"), "jpg") + writeFile(t, filepath.Join(base, "secret.png"), "secret") + writeFile(t, filepath.Join(cfg.SoundCheckPath, "check.mp3"), "sound check") + writeFile(t, filepath.Join(cfg.FrontendPath, "index.html"), "frontend") + + store := fakedb.New() + database := &fakeDatabase{} + s, err := New(cfg, store, database) + require.NoError(t, err) + t.Cleanup(func() { s.sync.Wait(); s.characters.Close() }) + return &testEnv{t: t, cfg: cfg, store: store, db: database, server: s, handler: s.httpServer.Handler} +} + +// addSong creates a song file and its database rows. +func (e *testEnv) addSong(soundtrack, song, content string) repository.Song { + e.t.Helper() + dir := e.cfg.MusicPath + "/" + soundtrack + "/" + fileName := song + ".mp3" + writeFile(e.t, dir+fileName, content) + st, ok := e.store.Soundtrack(soundtrack) + if !ok { + st = e.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: soundtrack, Path: dir}) + } + return e.store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: song, Path: dir + fileName, FileName: &fileName}) +} + +func (e *testEnv) do(method, path string, headers ...string) *httptest.ResponseRecorder { + return e.doBody(method, path, "", headers...) +} + +func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest.ResponseRecorder { + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + rec := httptest.NewRecorder() + e.handler.ServeHTTP(rec, req) + return rec +} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go new file mode 100644 index 0000000..3811d15 --- /dev/null +++ b/internal/server/routes_test.go @@ -0,0 +1,191 @@ +package server + +import ( + "context" + "encoding/json" + "net" + "net/http" + "sync/atomic" + "testing" + "time" + + "music-server/internal/backend" + "music-server/internal/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// blockSync replaces the sync service with one that keeps running until the returned function is called. +func (e *testEnv) blockSync() (release func()) { + ch := make(chan struct{}) + e.server.sync = backend.NewSyncService(e.store, e.cfg.MusicPath, func(context.Context) { <-ch }) + e.handler = e.server.RegisterRoutes() + return func() { close(ch); e.server.sync.Wait() } +} + +func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "a") + for _, prefix := range []string{"", "/api/v1"} { + for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} { + // GET falls through to the frontend's static files, which answer 404. + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path) + } + } + assert.False(t, e.server.sync.IsSyncing(), "no sync was started") + assert.Len(t, e.store.Songs, 1, "nothing was deleted") +} + +func TestSyncStartAndLock(t *testing.T) { + for _, prefix := range []string{"", "/api/v1"} { + t.Run("prefix="+prefix, func(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "a") + release := e.blockSync() + + assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code) + assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code) + assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code) + assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync") + + progress := e.do(http.MethodGet, prefix+"/sync/progress") + assert.Equal(t, http.StatusOK, progress.Code) + assert.Contains(t, progress.Body.String(), `"progress"`) + + release() + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code) + result := e.do(http.MethodGet, prefix+"/sync/progress") + assert.Contains(t, result.Body.String(), `"total_time"`) + }) + } +} + +func TestSyncResetEndpoint(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "a") + assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/sync/reset").Code) + assert.Empty(t, e.store.Soundtracks) + assert.Empty(t, e.store.Songs) +} + +func TestLegacyRoutesAreDeprecated(t *testing.T) { + e := newTestEnv(t) + rec := e.do(http.MethodGet, "/version") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "true", rec.Header().Get("Deprecation")) + + rec = e.do(http.MethodGet, "/api/v1/version") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, rec.Header().Get("Deprecation")) +} + +func TestVersionEndpoints(t *testing.T) { + e := newTestEnv(t) + var latest backend.VersionData + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/version").Body.Bytes(), &latest)) + assert.Equal(t, backend.GetLatestVersion(), latest) + + var history []backend.VersionData + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version/history").Body.Bytes(), &history)) + assert.Equal(t, backend.GetVersionHistory(), history) +} + +func TestHealthEndpoint(t *testing.T) { + e := newTestEnv(t) + rec := e.do(http.MethodGet, "/health") + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) +} + +func TestOpenAPIAndSwagger(t *testing.T) { + e := newTestEnv(t) + + for _, path := range []string{"/openapi", "/swagger/doc.json"} { + rec := e.do(http.MethodGet, path) + require.Equal(t, http.StatusOK, rec.Code, path) + var spec struct { + Paths map[string]map[string]any `json:"paths"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path) + assert.Contains(t, spec.Paths, "/api/v1/token", path) + assert.Contains(t, spec.Paths["/sync/reset"], "post", path) + } + + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code) +} + +func TestSearchPageAndFrontend(t *testing.T) { + e := newTestEnv(t) + e.addSong("Kingdom Hearts", "Dearly Beloved", "x") + + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/search").Code) + rec := e.do(http.MethodPost, "/find") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Kingdom Hearts") + + rec = e.do(http.MethodGet, "/") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "frontend") +} + +func TestShutdownWaitsForRequestsBeforeClosingDatabase(t *testing.T) { + e := newTestEnv(t) + started := make(chan struct{}) + release := make(chan struct{}) + var requestDone atomic.Bool + e.server.httpServer.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-release + requestDone.Store(true) + }) + var closedAfterRequest atomic.Bool + e.db.onClose = func() { closedAfterRequest.Store(requestDone.Load()) } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go e.server.httpServer.Serve(ln) + + responded := make(chan error, 1) + go func() { + resp, err := http.Get("http://" + ln.Addr().String()) + if err == nil { + resp.Body.Close() + } + responded <- err + }() + <-started + + shutdownDone := make(chan error, 1) + go func() { shutdownDone <- e.server.Shutdown(context.Background()) }() + + time.Sleep(50 * time.Millisecond) + assert.False(t, e.db.closed.Load(), "database must stay open while a request is running") + + close(release) + require.NoError(t, <-responded) + require.NoError(t, <-shutdownDone) + assert.True(t, e.db.closed.Load()) + assert.True(t, closedAfterRequest.Load()) +} + +func TestTrustedProxies(t *testing.T) { + // Without trusted proxies the forwarded header is ignored, so all requests share one bucket. + e := newTestEnv(t) + for i := range 10 { + e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0."+string(rune('0'+i))) + } + assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.1.1").Code) + + // httptest requests come from 192.0.2.1, which is trusted here. + e = newTestEnv(t, func(c *config.Config) { + _, trusted, _ := net.ParseCIDR("192.0.2.0/24") + c.TrustedProxies = []*net.IPNet{trusted} + }) + for range 10 { + e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1") + } + assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1").Code) + assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.2").Code) +} diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go new file mode 100644 index 0000000..d5751b0 --- /dev/null +++ b/internal/testutil/fakedb/fakedb.go @@ -0,0 +1,327 @@ +// Package fakedb provides an in-memory repository.Querier for tests. +package fakedb + +import ( + "cmp" + "context" + "math/rand/v2" + "slices" + "sync" + "time" + + "music-server/internal/db/repository" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Store is an in-memory implementation of the queries used by the application. +// Calling a query it does not implement panics through the nil embedded interface. +type Store struct { + repository.Querier + + mu sync.Mutex + nextID int32 + Soundtracks []repository.Soundtrack + Songs []repository.Song + Sessions map[string]repository.Session + + // Err, when set, is returned by every implemented query. + Err error +} + +// New returns an empty Store. +func New() *Store { + return &Store{Sessions: map[string]repository.Session{}} +} + +func (s *Store) lock() func() { + s.mu.Lock() + return s.mu.Unlock +} + +// AddSoundtrack inserts a soundtrack and returns it with its ID and UUID set. +func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack { + defer s.lock()() + s.nextID++ + st.ID = s.nextID + if st.Uuid == uuid.Nil { + st.Uuid = uuid.New() + } + s.Soundtracks = append(s.Soundtracks, st) + return st +} + +// AddTestSong inserts a song and returns it with its UUID set. +func (s *Store) AddTestSong(song repository.Song) repository.Song { + defer s.lock()() + if song.Uuid == uuid.Nil { + song.Uuid = uuid.New() + } + s.Songs = append(s.Songs, song) + return song +} + +// Soundtrack returns the soundtrack with the given name. +func (s *Store) Soundtrack(name string) (repository.Soundtrack, bool) { + defer s.lock()() + for _, st := range s.Soundtracks { + if st.SoundtrackName == name { + return st, true + } + } + return repository.Soundtrack{}, false +} + +// SongsOf returns the songs of a soundtrack sorted by path. +func (s *Store) SongsOf(soundtrackID int32) []repository.Song { + defer s.lock()() + var songs []repository.Song + for _, song := range s.Songs { + if song.SoundtrackID == soundtrackID { + songs = append(songs, song) + } + } + slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.Path, b.Path) }) + return songs +} + +func (s *Store) soundtrackIndex(id int32) int { + return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.ID == id }) +} + +func (s *Store) updateSoundtrack(id int32, f func(*repository.Soundtrack)) error { + defer s.lock()() + if s.Err != nil { + return s.Err + } + if i := s.soundtrackIndex(id); i >= 0 { + f(&s.Soundtracks[i]) + } + return nil +} + +func sortedByName(soundtracks []repository.Soundtrack) []repository.Soundtrack { + slices.SortFunc(soundtracks, func(a, b repository.Soundtrack) int { return cmp.Compare(a.SoundtrackName, b.SoundtrackName) }) + return soundtracks +} + +func (s *Store) FindAllSoundtracks(context.Context) ([]repository.Soundtrack, error) { + defer s.lock()() + if s.Err != nil { + return nil, s.Err + } + var active []repository.Soundtrack + for _, st := range s.Soundtracks { + if st.Deleted == nil { + active = append(active, st) + } + } + return sortedByName(active), nil +} + +func (s *Store) GetAllSoundtracksIncludingDeleted(context.Context) ([]repository.Soundtrack, error) { + defer s.lock()() + if s.Err != nil { + return nil, s.Err + } + return sortedByName(slices.Clone(s.Soundtracks)), nil +} + +func (s *Store) InsertSoundtrack(_ context.Context, arg repository.InsertSoundtrackParams) (repository.InsertSoundtrackRow, error) { + if s.Err != nil { + return repository.InsertSoundtrackRow{}, s.Err + } + st := s.AddSoundtrack(repository.Soundtrack{ + SoundtrackName: arg.SoundtrackName, + Path: arg.Path, + Hash: arg.Hash, + Uuid: arg.Uuid, + Added: time.Now(), + }) + return repository.InsertSoundtrackRow{ID: st.ID, Uuid: st.Uuid}, nil +} + +func (s *Store) ResetSoundtrackIdSeq(context.Context) (int64, error) { + return int64(s.nextID), s.Err +} + +func (s *Store) UpdateSoundtrackHash(_ context.Context, arg repository.UpdateSoundtrackHashParams) error { + return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Hash = arg.Hash }) +} + +func (s *Store) UpdateSoundtrackName(_ context.Context, arg repository.UpdateSoundtrackNameParams) error { + return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.SoundtrackName, st.Path = arg.Name, arg.Path }) +} + +func (s *Store) SetSoundtrackFingerprint(_ context.Context, arg repository.SetSoundtrackFingerprintParams) error { + return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Fingerprint = arg.Fingerprint }) +} + +func (s *Store) RemoveSoundtrackDeletionDate(_ context.Context, id int32) error { + return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.Deleted = nil }) +} + +func (s *Store) AddSoundtrackPlayed(_ context.Context, id int32) error { + now := time.Now() + return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.TimesPlayed++; st.LastPlayed = &now }) +} + +func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error { + defer s.lock()() + if s.Err != nil { + return s.Err + } + now := time.Now() + for i := range s.Soundtracks { + if s.Soundtracks[i].Deleted == nil && !slices.Contains(ids, s.Soundtracks[i].ID) { + s.Soundtracks[i].Deleted = &now + } + } + return nil +} + +func (s *Store) ClearSoundtracks(context.Context) error { + defer s.lock()() + s.Soundtracks = nil + return s.Err +} + +func (s *Store) ClearSongs(context.Context) error { + defer s.lock()() + s.Songs = nil + return s.Err +} + +func (s *Store) FindSongsFromSoundtrack(_ context.Context, soundtrackID int32) ([]repository.Song, error) { + if s.Err != nil { + return nil, s.Err + } + return s.SongsOf(soundtrackID), nil +} + +func (s *Store) FetchAllSongs(context.Context) ([]repository.Song, error) { + defer s.lock()() + return slices.Clone(s.Songs), s.Err +} + +func (s *Store) AddSong(_ context.Context, arg repository.AddSongParams) error { + if s.Err != nil { + return s.Err + } + s.AddTestSong(repository.Song{ + SoundtrackID: arg.SoundtrackID, + SongName: arg.SongName, + Path: arg.Path, + FileName: arg.FileName, + Hash: arg.Hash, + Uuid: arg.Uuid, + SoundtrackUuid: arg.SoundtrackUuid, + }) + return nil +} + +func (s *Store) UpdateSongByUUID(_ context.Context, arg repository.UpdateSongByUUIDParams) error { + defer s.lock()() + for i := range s.Songs { + if s.Songs[i].Uuid == arg.Uuid { + s.Songs[i].SongName, s.Songs[i].FileName, s.Songs[i].Path, s.Songs[i].Hash = arg.SongName, arg.FileName, arg.Path, arg.Hash + } + } + return s.Err +} + +func (s *Store) AddSongPlayed(_ context.Context, arg repository.AddSongPlayedParams) error { + defer s.lock()() + for i := range s.Songs { + if s.Songs[i].SoundtrackID == arg.SoundtrackID && s.Songs[i].SongName == arg.SongName { + s.Songs[i].TimesPlayed++ + } + } + return s.Err +} + +func (s *Store) RemoveBrokenSong(_ context.Context, arg repository.RemoveBrokenSongParams) error { + defer s.lock()() + s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool { + return song.SoundtrackID == arg.SoundtrackID && song.Path == arg.Path + }) + return s.Err +} + +func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error { + defer s.lock()() + s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool { return slices.Contains(uuids, song.Uuid) }) + return s.Err +} + +func (s *Store) GetRandomActiveSong(context.Context) (repository.GetRandomActiveSongRow, error) { + defer s.lock()() + if s.Err != nil { + return repository.GetRandomActiveSongRow{}, s.Err + } + var rows []repository.GetRandomActiveSongRow + for _, song := range s.Songs { + if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil { + rows = append(rows, repository.GetRandomActiveSongRow{Song: song, SoundtrackPath: s.Soundtracks[i].Path}) + } + } + if len(rows) == 0 { + return repository.GetRandomActiveSongRow{}, pgx.ErrNoRows + } + return rows[rand.IntN(len(rows))], nil +} + +func (s *Store) CreateSession(_ context.Context, arg repository.CreateSessionParams) (repository.Session, error) { + defer s.lock()() + if s.Err != nil { + return repository.Session{}, s.Err + } + session := repository.Session{ + Token: arg.Token, + IpAddress: arg.IpAddress, + UserAgent: arg.UserAgent, + ClientType: arg.ClientType, + ExpiresAt: arg.ExpiresAt, + } + s.Sessions[arg.Token] = session + return session, nil +} + +func (s *Store) GetSession(_ context.Context, token string) (repository.Session, error) { + defer s.lock()() + if s.Err != nil { + return repository.Session{}, s.Err + } + session, ok := s.Sessions[token] + if !ok { + return repository.Session{}, pgx.ErrNoRows + } + return session, nil +} + +func (s *Store) DeleteSession(ctx context.Context, token string) error { + if err := ctx.Err(); err != nil { + return err + } + defer s.lock()() + delete(s.Sessions, token) + return s.Err +} + +func (s *Store) DeleteExpiredSessions(context.Context) error { + defer s.lock()() + for token, session := range s.Sessions { + if session.ExpiresAt.Time.Before(time.Now()) { + delete(s.Sessions, token) + } + } + return s.Err +} + +// HasSession reports whether a session with the given token exists. +func (s *Store) HasSession(token string) bool { + defer s.lock()() + _, ok := s.Sessions[token] + return ok +} diff --git a/internal/testutil/pgtest/pgtest.go b/internal/testutil/pgtest/pgtest.go new file mode 100644 index 0000000..c95f916 --- /dev/null +++ b/internal/testutil/pgtest/pgtest.go @@ -0,0 +1,154 @@ +//go:build integration + +// Package pgtest starts a PostgreSQL container for integration tests. +// +// Use it from TestMain: +// +// func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) } +package pgtest + +import ( + "context" + "fmt" + "io" + "log" + "strings" + "sync/atomic" + "testing" + "time" + + "music-server/internal/db" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + user = "postgres" + password = "postgres" +) + +var ( + container testcontainers.Container + host string + port string + counter atomic.Int32 +) + +// Main starts PostgreSQL, runs the tests and stops PostgreSQL again. +func Main(m *testing.M) int { + ctx := context.Background() + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "postgres:15-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{"POSTGRES_USER": user, "POSTGRES_PASSWORD": password}, + WaitingFor: wait.ForAll( + wait.ForLog("database system is ready to accept connections").WithOccurrence(2), + wait.ForListeningPort("5432/tcp"), + ).WithDeadline(2 * time.Minute), + }, + Started: true, + }) + if err != nil { + log.Printf("failed to start PostgreSQL container (is Docker running?): %v", err) + return 1 + } + defer func() { + if err := c.Terminate(ctx); err != nil { + log.Printf("failed to stop PostgreSQL container: %v", err) + } + }() + + container = c + host, err = c.Host(ctx) + if err != nil { + log.Printf("failed to get container host: %v", err) + return 1 + } + mapped, err := c.MappedPort(ctx, "5432/tcp") + if err != nil { + log.Printf("failed to get container port: %v", err) + return 1 + } + port = mapped.Port() + return m.Run() +} + +// ConnString returns the connection string for a database in the container. +func ConnString(dbname string) string { + return db.ConnString(host, port, user, password, dbname) +} + +// NewDatabase creates an empty database and returns its name. It is dropped when the test ends. +func NewDatabase(t *testing.T) string { + t.Helper() + ctx := context.Background() + name := fmt.Sprintf("test_%d_%d", time.Now().UnixNano()%1_000_000, counter.Add(1)) + + admin, err := pgxpool.New(ctx, ConnString("postgres")) + if err != nil { + t.Fatalf("connect to postgres: %v", err) + } + defer admin.Close() + if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil { + t.Fatalf("create database: %v", err) + } + t.Cleanup(func() { + admin, err := pgxpool.New(ctx, ConnString("postgres")) + if err != nil { + return + } + defer admin.Close() + admin.Exec(ctx, "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)") + }) + return name +} + +// NewMigratedDatabase creates a database with all migrations applied. +func NewMigratedDatabase(t *testing.T) *db.Database { + t.Helper() + database, err := db.NewDatabase(context.Background(), ConnString(NewDatabase(t))) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(database.Close) + if err := database.RunMigrations(); err != nil { + t.Fatalf("run migrations: %v", err) + } + return database +} + +// LoadSQLFile runs a SQL file with psql inside the container, as the superuser. +func LoadSQLFile(t *testing.T, dbname, path string) { + t.Helper() + ctx := context.Background() + target := "/tmp/" + dbname + ".sql" + if err := container.CopyFileToContainer(ctx, path, target, 0o644); err != nil { + t.Fatalf("copy %s to container: %v", path, err) + } + code, reader, err := container.Exec(ctx, []string{"psql", "-U", user, "-d", dbname, "-v", "ON_ERROR_STOP=1", "-q", "-f", target}) + if err != nil { + t.Fatalf("run psql: %v", err) + } + output, _ := io.ReadAll(reader) + if code != 0 { + t.Fatalf("psql exited with %d: %s", code, output) + } +} + +// CreateRole creates a login role, used by dumps that reference their original owner. +func CreateRole(t *testing.T, name string) { + t.Helper() + ctx := context.Background() + admin, err := pgxpool.New(ctx, ConnString("postgres")) + if err != nil { + t.Fatalf("connect to postgres: %v", err) + } + defer admin.Close() + _, err = admin.Exec(ctx, "CREATE ROLE "+name+" LOGIN") + if err != nil && !strings.Contains(err.Error(), "already exists") { + t.Fatalf("create role: %v", err) + } +} diff --git a/testMusic/A Bird Story/1. A Bird Story (Title Theme).mp3 b/testMusic/A Bird Story/1. A Bird Story (Title Theme).mp3 index 53b2e7d..3f6a8f6 100644 Binary files a/testMusic/A Bird Story/1. A Bird Story (Title Theme).mp3 and b/testMusic/A Bird Story/1. A Bird Story (Title Theme).mp3 differ diff --git a/testMusic/A Bird Story/2. Yesterday.mp3 b/testMusic/A Bird Story/2. Yesterday.mp3 index 55a332d..34be5f0 100644 Binary files a/testMusic/A Bird Story/2. Yesterday.mp3 and b/testMusic/A Bird Story/2. Yesterday.mp3 differ diff --git a/testMusic/A Bird Story/3. Yesterday (Reversing).mp3 b/testMusic/A Bird Story/3. Yesterday (Reversing).mp3 index f9cb622..5142bdf 100644 Binary files a/testMusic/A Bird Story/3. Yesterday (Reversing).mp3 and b/testMusic/A Bird Story/3. Yesterday (Reversing).mp3 differ diff --git a/testMusic/A Bird Story/38. Bestest Detectives in the World - Winter Vers. (Holiday Special).mp3 b/testMusic/A Bird Story/38. Bestest Detectives in the World - Winter Vers. (Holiday Special).mp3 index b5ac107..24ffd79 100644 Binary files a/testMusic/A Bird Story/38. Bestest Detectives in the World - Winter Vers. (Holiday Special).mp3 and b/testMusic/A Bird Story/38. Bestest Detectives in the World - Winter Vers. (Holiday Special).mp3 differ diff --git a/testMusic/A Bird Story/39. To the Moon - For River - Minigame Vers. (Holiday Special).mp3 b/testMusic/A Bird Story/39. To the Moon - For River - Minigame Vers. (Holiday Special).mp3 index 131b390..6183d25 100644 Binary files a/testMusic/A Bird Story/39. To the Moon - For River - Minigame Vers. (Holiday Special).mp3 and b/testMusic/A Bird Story/39. To the Moon - For River - Minigame Vers. (Holiday Special).mp3 differ diff --git a/testMusic/A Bird Story/40. A Bird Story (Piano).mp3 b/testMusic/A Bird Story/40. A Bird Story (Piano).mp3 index 605ec72..0a14ad5 100644 Binary files a/testMusic/A Bird Story/40. A Bird Story (Piano).mp3 and b/testMusic/A Bird Story/40. A Bird Story (Piano).mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 01 Alicia.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 01 Alicia.mp3 index d010d9f..593bba1 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 01 Alicia.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 01 Alicia.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 02 Gustave.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 02 Gustave.mp3 index 3d83d5b..bb9078d 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 02 Gustave.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 02 Gustave.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 03 Lumière.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 03 Lumière.mp3 index bf8cbb9..2e9064a 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 03 Lumière.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 03 Lumière.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 144 Renoir.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 144 Renoir.mp3 index 68356dc..6bc3898 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 144 Renoir.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 144 Renoir.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 145 Une vie à peindre.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 145 Une vie à peindre.mp3 index 2f4bf72..773398a 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 145 Une vie à peindre.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 145 Une vie à peindre.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 152 Une vie à rêver.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 152 Une vie à rêver.mp3 index 7cd5ac2..2142c89 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 152 Une vie à rêver.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 152 Une vie à rêver.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 22 Lune.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 22 Lune.mp3 index 3a85c51..50017ac 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 22 Lune.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 22 Lune.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 28 Flying Waters - Electric Tides.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 28 Flying Waters - Electric Tides.mp3 index b43c095..08cddce 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 28 Flying Waters - Electric Tides.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 28 Flying Waters - Electric Tides.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 60 Une vie à t'aimer.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 60 Une vie à t'aimer.mp3 index bfe8496..b721fb1 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 60 Une vie à t'aimer.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 60 Une vie à t'aimer.mp3 differ diff --git a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 77 Monoco.mp3 b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 77 Monoco.mp3 index 4aea612..fb0248d 100644 Binary files a/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 77 Monoco.mp3 and b/testMusic/Clair Obscur - Expedition 33/Clair Obscur- Expedition 33 - 77 Monoco.mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/01 - Rules of Nature (Platinum Mix).mp3 b/testMusic/Metal Gear Rising - Revengeance/01 - Rules of Nature (Platinum Mix).mp3 index 10a4a33..7cc906b 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/01 - Rules of Nature (Platinum Mix).mp3 and b/testMusic/Metal Gear Rising - Revengeance/01 - Rules of Nature (Platinum Mix).mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/04 - I'm My Own Master Now (Platinum Mix).mp3 b/testMusic/Metal Gear Rising - Revengeance/04 - I'm My Own Master Now (Platinum Mix).mp3 index 78ea663..51ad82d 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/04 - I'm My Own Master Now (Platinum Mix).mp3 and b/testMusic/Metal Gear Rising - Revengeance/04 - I'm My Own Master Now (Platinum Mix).mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/05 - A Stranger I Remain (Maniac Agenda Mix).mp3 b/testMusic/Metal Gear Rising - Revengeance/05 - A Stranger I Remain (Maniac Agenda Mix).mp3 index 459399e..de09086 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/05 - A Stranger I Remain (Maniac Agenda Mix).mp3 and b/testMusic/Metal Gear Rising - Revengeance/05 - A Stranger I Remain (Maniac Agenda Mix).mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/08 - Red Sun (Maniac Agenda Mix).mp3 b/testMusic/Metal Gear Rising - Revengeance/08 - Red Sun (Maniac Agenda Mix).mp3 index eedc15f..261990b 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/08 - Red Sun (Maniac Agenda Mix).mp3 and b/testMusic/Metal Gear Rising - Revengeance/08 - Red Sun (Maniac Agenda Mix).mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/09 - A Soul Can't Be Cut (Platinum Mix).mp3 b/testMusic/Metal Gear Rising - Revengeance/09 - A Soul Can't Be Cut (Platinum Mix).mp3 index b848e31..88f493e 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/09 - A Soul Can't Be Cut (Platinum Mix).mp3 and b/testMusic/Metal Gear Rising - Revengeance/09 - A Soul Can't Be Cut (Platinum Mix).mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/11 - It Has to Be This Way (Platinum Mix).mp3 b/testMusic/Metal Gear Rising - Revengeance/11 - It Has to Be This Way (Platinum Mix).mp3 index 44afa40..a9c4f5e 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/11 - It Has to Be This Way (Platinum Mix).mp3 and b/testMusic/Metal Gear Rising - Revengeance/11 - It Has to Be This Way (Platinum Mix).mp3 differ diff --git a/testMusic/Metal Gear Rising - Revengeance/12 - The War Still Rages Within.mp3 b/testMusic/Metal Gear Rising - Revengeance/12 - The War Still Rages Within.mp3 index b9e693f..92224a8 100644 Binary files a/testMusic/Metal Gear Rising - Revengeance/12 - The War Still Rages Within.mp3 and b/testMusic/Metal Gear Rising - Revengeance/12 - The War Still Rages Within.mp3 differ