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:
1 parent
090e794460
commit
7b144ae419
42 files changed
+2334
-40
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user