- Unit tests use an in-memory fake database (internal/testutil/fakedb), temp folders and httptest; `go test ./...` needs no Docker or env vars - Integration tests (build tag "integration") start PostgreSQL with testcontainers: migrations step by step, SQL dumps (skipped if missing) and a full sync against a real database - Removed tests that only checked copies of the logic or had empty bodies, the broken root integration_test.go, compose.test.yaml and .env.test with hardcoded paths - testMusic MP3s replaced with tiny placeholders (sync never decodes audio), 194 MB -> 168 KB
399 lines
13 KiB
Go
399 lines
13 KiB
Go
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))
|
|
}
|