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

+58
View File
@@ -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)
}
+15
View File
@@ -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())
}
+224
View File
@@ -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)
}
+81
View File
@@ -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)
}
+67
View File
@@ -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)
}
+111
View File
@@ -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)
}
+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))
}