Files
MusicServer/internal/backend/sync_test.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

406 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.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{
SoundtracksAdded: []string{},
SoundtracksReAdded: []string{},
SoundtracksChangedTitle: map[string]string{},
SoundtracksChangedContent: []string{},
SoundtracksRemoved: []string{},
CatchedErrors: []string{},
}, s.Result(), "lists are empty, not null, so the JSON matches the API schema")
}
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))
}