Rewrite tests to run without a database

- 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
This commit is contained in:
Sansan committed 2026-10-06 15:20:31 +02:00
1 parent 090e794460
commit 7b144ae419
42 files changed
+2334 -40

No files matched your search

+398
View File
@@ -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))
}