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.AddSongPlayedByUUID(context.Background(), f.store.SongsOf(a.ID)[0].Uuid)) 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)) }