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))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func env(values map[string]string) func(string) string {
|
||||
return func(key string) string { return values[key] }
|
||||
}
|
||||
|
||||
func validEnv() map[string]string {
|
||||
return map[string]string{
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"DB_USERNAME": "user",
|
||||
"DB_PASSWORD": "pass",
|
||||
"DB_NAME": "music",
|
||||
"MUSIC_PATH": "/music",
|
||||
"CHARACTERS_PATH": "/characters",
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := load(env(validEnv()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
assert.Equal(t, "info", cfg.LogLevel)
|
||||
assert.Equal(t, "songs", cfg.SoundCheckPath)
|
||||
assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL)
|
||||
assert.Empty(t, cfg.TrustedProxies)
|
||||
}
|
||||
|
||||
func TestLoadMissingRequired(t *testing.T) {
|
||||
values := validEnv()
|
||||
delete(values, "MUSIC_PATH")
|
||||
delete(values, "DB_HOST")
|
||||
_, err := load(env(values))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "MUSIC_PATH is required")
|
||||
assert.Contains(t, err.Error(), "DB_HOST is required")
|
||||
}
|
||||
|
||||
func TestLoadTrustedProxies(t *testing.T) {
|
||||
values := validEnv()
|
||||
values["TRUSTED_PROXIES"] = "10.0.0.0/8, 172.16.0.0/12"
|
||||
cfg, err := load(env(values))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cfg.TrustedProxies, 2)
|
||||
assert.Equal(t, "10.0.0.0/8", cfg.TrustedProxies[0].String())
|
||||
|
||||
values["TRUSTED_PROXIES"] = "not-a-cidr"
|
||||
_, err = load(env(values))
|
||||
assert.ErrorContains(t, err, "TRUSTED_PROXIES")
|
||||
}
|
||||
|
||||
func TestLoadInvalidPort(t *testing.T) {
|
||||
values := validEnv()
|
||||
values["PORT"] = "abc"
|
||||
_, err := load(env(values))
|
||||
assert.ErrorContains(t, err, "PORT")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build integration
|
||||
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/testutil/pgtest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logging.Init("error", false)
|
||||
os.Exit(pgtest.Main(m))
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build integration
|
||||
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/testutil/pgtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func openDatabase(t *testing.T, dbname string) *db.Database {
|
||||
t.Helper()
|
||||
database, err := db.NewDatabase(context.Background(), pgtest.ConnString(dbname))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(database.Close)
|
||||
return database
|
||||
}
|
||||
|
||||
func count(t *testing.T, database *db.Database, query string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
require.NoError(t, database.Pool.QueryRow(context.Background(), query).Scan(&n))
|
||||
return n
|
||||
}
|
||||
|
||||
// TestMigrationsKeepData applies the migrations step by step with data in between,
|
||||
// like a database that was created by an older version.
|
||||
func TestMigrationsKeepData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openDatabase(t, pgtest.NewDatabase(t))
|
||||
m, closeFn, err := database.NewMigrator()
|
||||
require.NoError(t, err)
|
||||
defer closeFn()
|
||||
|
||||
require.NoError(t, m.Steps(4))
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
_, err := database.Pool.Exec(ctx, `INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, NOW())`,
|
||||
fmt.Sprintf("Manual Game %d", i), fmt.Sprintf("/music/game%d/", i), fmt.Sprintf("hash-%d", i))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for i, game := range []int{1, 1, 2, 3, 4, 4, 4, 5, 5} {
|
||||
_, err := database.Pool.Exec(ctx, `INSERT INTO song (game_id, song_name, path, hash) VALUES ($1, $2, $3, $4)`,
|
||||
game, fmt.Sprintf("Song %d", i), fmt.Sprintf("/music/game%d/%d.mp3", game, i), fmt.Sprintf("song-hash-%d", i))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NoError(t, m.Up())
|
||||
version, dirty, err := m.Version()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, dirty)
|
||||
assert.Equal(t, uint(6), version)
|
||||
|
||||
assert.Equal(t, 5, count(t, database, "SELECT COUNT(*) FROM soundtrack"))
|
||||
assert.Equal(t, 9, count(t, database, "SELECT COUNT(*) FROM song"))
|
||||
assert.Equal(t, 0, count(t, database, "SELECT COUNT(*) FROM song WHERE uuid IS NULL OR soundtrack_uuid IS NULL"))
|
||||
assert.Equal(t, 5, count(t, database, "SELECT COUNT(*) FROM soundtrack WHERE fingerprint = ''"))
|
||||
_, err = database.Pool.Exec(ctx, "SELECT 1 FROM game")
|
||||
assert.Error(t, err, "game table is renamed")
|
||||
}
|
||||
|
||||
func TestFingerprintMigrationDown(t *testing.T) {
|
||||
database := openDatabase(t, pgtest.NewDatabase(t))
|
||||
m, closeFn, err := database.NewMigrator()
|
||||
require.NoError(t, err)
|
||||
defer closeFn()
|
||||
|
||||
require.NoError(t, m.Up())
|
||||
require.NoError(t, m.Steps(-1))
|
||||
assert.Equal(t, 0, count(t, database,
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'"))
|
||||
require.NoError(t, m.Up())
|
||||
}
|
||||
|
||||
func TestRunMigrationsIsIdempotent(t *testing.T) {
|
||||
database := openDatabase(t, pgtest.NewDatabase(t))
|
||||
require.NoError(t, database.RunMigrations())
|
||||
require.NoError(t, database.RunMigrations())
|
||||
assert.Equal(t, "up", database.Health(context.Background())["status"])
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build integration
|
||||
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/testutil/pgtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSQLDumps migrates real database dumps (music_*_dump.sql in the repository root, not committed).
|
||||
func TestSQLDumps(t *testing.T) {
|
||||
for _, dump := range []string{"music_ref_dump.sql", "music_test_dump.sql", "music_prod_dump.sql"} {
|
||||
t.Run(dump, func(t *testing.T) {
|
||||
path := filepath.Join("..", "..", dump)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("%s not found", dump)
|
||||
}
|
||||
|
||||
pgtest.CreateRole(t, "sansan")
|
||||
dbname := pgtest.NewDatabase(t)
|
||||
pgtest.LoadSQLFile(t, dbname, path)
|
||||
database := openDatabase(t, dbname)
|
||||
soundtracksBefore := count(t, database, "SELECT COUNT(*) FROM game")
|
||||
songsBefore := count(t, database, "SELECT COUNT(*) FROM song")
|
||||
|
||||
require.NoError(t, database.RunMigrations())
|
||||
|
||||
assert.Equal(t, soundtracksBefore, count(t, database, "SELECT COUNT(*) FROM soundtrack"))
|
||||
assert.Equal(t, songsBefore, count(t, database, "SELECT COUNT(*) FROM song"))
|
||||
assert.Equal(t, 0, count(t, database, "SELECT COUNT(*) FROM song WHERE uuid IS NULL"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMusicRandomAndQueue(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "0123456789")
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/rand")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "0123456789", rec.Body.String())
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addPlayed").Code)
|
||||
|
||||
var info backend.SongInfo
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info))
|
||||
assert.Equal(t, "Game A", info.Soundtrack)
|
||||
assert.Equal(t, "Song", info.Song)
|
||||
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/api/v1/music?song=0"} {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code)
|
||||
}
|
||||
|
||||
func TestMusicRangeRequest(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "0123456789")
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/rand", "Range", "bytes=2-5")
|
||||
assert.Equal(t, http.StatusPartialContent, rec.Code)
|
||||
assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range"))
|
||||
assert.Equal(t, "2345", rec.Body.String())
|
||||
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestMusicEmptyStates(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code)
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/list")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "null\n", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicSoundCheck(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/music/soundTest")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "sound check", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicSoundtrackLists(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("B Game", "Song", "b")
|
||||
e.addSong("A Game", "Song", "a")
|
||||
|
||||
var ordered []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all/order").Body.Bytes(), &ordered))
|
||||
assert.Equal(t, []string{"A Game", "B Game"}, ordered)
|
||||
|
||||
var random []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all").Body.Bytes(), &random))
|
||||
assert.ElementsMatch(t, ordered, random)
|
||||
}
|
||||
|
||||
func TestMusicDatabaseError(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.store.Err = errors.New("db down")
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/rand").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/all/order").Code)
|
||||
}
|
||||
|
||||
func TestCharacterEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
var list []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/characters").Body.Bytes(), &list))
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
|
||||
|
||||
rec := e.do(http.MethodGet, "/character?name=Adol.png")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/character?name=Aloy.jpg")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/character?name=Missing.png").Code)
|
||||
}
|
||||
|
||||
func TestCharacterPathTraversal(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} {
|
||||
rec := e.do(http.MethodGet, "/character?name="+name)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, name)
|
||||
assert.NotContains(t, rec.Body.String(), "secret", name)
|
||||
}
|
||||
}
|
||||
|
||||
func newGitea(t *testing.T, status int) string {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(`{"id":1,"name":"v2.0.0","assets":[{"name":"Player.exe","browser_download_url":"https://example.com/Player.exe"}]}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server.URL
|
||||
}
|
||||
|
||||
func TestDownloadEndpoints(t *testing.T) {
|
||||
url := newGitea(t, http.StatusOK)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url })
|
||||
|
||||
rec := e.do(http.MethodGet, "/download")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `"v2.0.0"`, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/download/list")
|
||||
assert.JSONEq(t, `["Player.exe"]`, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, "/download/windows")
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location"))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/download/linux").Code)
|
||||
}
|
||||
|
||||
func TestDownloadUpstreamDown(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) })
|
||||
for _, path := range []string{"/download", "/download/list", "/download/windows"} {
|
||||
assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
func withAuth(password string) func(*config.Config) {
|
||||
return func(c *config.Config) {
|
||||
c.DisableSessionAuth = false
|
||||
c.APIPassword = password
|
||||
}
|
||||
}
|
||||
|
||||
func (e *testEnv) createToken(password string) string {
|
||||
e.t.Helper()
|
||||
rec := e.doBody(http.MethodPost, "/api/v1/token", `{"password":"`+password+`"}`)
|
||||
require.Equal(e.t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var resp TokenResponse
|
||||
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(e.t, "web", resp.ClientType)
|
||||
assert.WithinDuration(e.t, time.Now().Add(tokenLifetime), resp.ExpiresAt, time.Minute)
|
||||
return resp.Token
|
||||
}
|
||||
|
||||
func TestTokenAuth(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version").Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Basic abc").Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer unknown").Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.doBody(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`).Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPost, "/api/v1/token", `{not json`).Code)
|
||||
|
||||
token := e.createToken("secret")
|
||||
auth := []string{"Authorization", "Bearer " + token}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/version").Code, "legacy routes need no token")
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, "/api/v1/token").Code)
|
||||
}
|
||||
|
||||
func TestTokenOpenAccessWithoutPassword(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth(""))
|
||||
token := e.createToken("anything")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer "+token).Code)
|
||||
}
|
||||
|
||||
func TestTokenCleanupRequiresAuth(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodPost, "/api/v1/token/cleanup").Code)
|
||||
|
||||
token := e.createToken("secret")
|
||||
e.store.Sessions["old"] = repository.Session{Token: "old", ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/token/cleanup", "Authorization", "Bearer "+token).Code)
|
||||
assert.False(t, e.store.HasSession("old"))
|
||||
assert.True(t, e.store.HasSession(token))
|
||||
}
|
||||
|
||||
func TestExpiredTokenIsDeleted(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
_, err := e.store.CreateSession(context.Background(), repository.CreateSessionParams{
|
||||
Token: "expired",
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Minute), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer expired")
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "Token expired")
|
||||
// The fake refuses to delete with a cancelled context, like a real database would.
|
||||
assert.Eventually(t, func() bool { return !e.store.HasSession("expired") }, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestTokenRateLimit(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for range 10 {
|
||||
assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`).Code)
|
||||
}
|
||||
rec := e.doBody(http.MethodPost, "/api/v1/token", `{}`)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "Rate limit exceeded")
|
||||
}
|
||||
|
||||
// statsStore returns canned statistics.
|
||||
type statsStore struct {
|
||||
repository.Querier
|
||||
err error
|
||||
}
|
||||
|
||||
func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||
return []repository.GetMostPlayedSoundtracksWithSongsRow{{
|
||||
SoundtrackID: int32(limit),
|
||||
SoundtrackName: "Game A",
|
||||
Songs: []byte(`[{"song_name":"Song","times_played":3}]`),
|
||||
}}, s.err
|
||||
}
|
||||
|
||||
func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
|
||||
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
|
||||
}
|
||||
|
||||
func TestStatisticsEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.server.stats = backend.NewStatisticsService(statsStore{})
|
||||
e.handler = e.server.RegisterRoutes()
|
||||
|
||||
var soundtracks []backend.SoundtrackWithSongs
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played").Body.Bytes(), &soundtracks))
|
||||
require.Len(t, soundtracks, 1)
|
||||
assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit")
|
||||
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
|
||||
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit=500").Body.Bytes(), &soundtracks))
|
||||
assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped")
|
||||
|
||||
for _, limit := range []string{"0", "-1", "abc"} {
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
|
||||
}
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
|
||||
assert.Equal(t, backend.StatisticsSummary{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, summary)
|
||||
|
||||
e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")})
|
||||
e.handler = e.server.RegisterRoutes()
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/summary").Code)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logging.Init("fatal", false)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type fakeDatabase struct {
|
||||
closed atomic.Bool
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func (f *fakeDatabase) Health(context.Context) map[string]string {
|
||||
return map[string]string{"status": "up"}
|
||||
}
|
||||
|
||||
func (f *fakeDatabase) Close() {
|
||||
if f.onClose != nil {
|
||||
f.onClose()
|
||||
}
|
||||
f.closed.Store(true)
|
||||
}
|
||||
|
||||
// testEnv is a Server backed by an in-memory database and temporary folders.
|
||||
type testEnv struct {
|
||||
t *testing.T
|
||||
cfg config.Config
|
||||
store *fakedb.Store
|
||||
db *fakeDatabase
|
||||
server *Server
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T, configure ...func(*config.Config)) *testEnv {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Port: 0,
|
||||
MusicPath: filepath.Join(base, "music"),
|
||||
CharactersPath: filepath.Join(base, "characters"),
|
||||
SoundCheckPath: filepath.Join(base, "songs"),
|
||||
FrontendPath: filepath.Join(base, "frontend"),
|
||||
DisableSessionAuth: true,
|
||||
ReleasesURL: "http://127.0.0.1:1",
|
||||
}
|
||||
for _, c := range configure {
|
||||
c(&cfg)
|
||||
}
|
||||
writeFile(t, filepath.Join(cfg.CharactersPath, "Adol.png"), "png")
|
||||
writeFile(t, filepath.Join(cfg.CharactersPath, "Aloy.jpg"), "jpg")
|
||||
writeFile(t, filepath.Join(base, "secret.png"), "secret")
|
||||
writeFile(t, filepath.Join(cfg.SoundCheckPath, "check.mp3"), "sound check")
|
||||
writeFile(t, filepath.Join(cfg.FrontendPath, "index.html"), "<html>frontend</html>")
|
||||
|
||||
store := fakedb.New()
|
||||
database := &fakeDatabase{}
|
||||
s, err := New(cfg, store, database)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { s.sync.Wait(); s.characters.Close() })
|
||||
return &testEnv{t: t, cfg: cfg, store: store, db: database, server: s, handler: s.httpServer.Handler}
|
||||
}
|
||||
|
||||
// addSong creates a song file and its database rows.
|
||||
func (e *testEnv) addSong(soundtrack, song, content string) repository.Song {
|
||||
e.t.Helper()
|
||||
dir := e.cfg.MusicPath + "/" + soundtrack + "/"
|
||||
fileName := song + ".mp3"
|
||||
writeFile(e.t, dir+fileName, content)
|
||||
st, ok := e.store.Soundtrack(soundtrack)
|
||||
if !ok {
|
||||
st = e.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: soundtrack, Path: dir})
|
||||
}
|
||||
return e.store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: song, Path: dir + fileName, FileName: &fileName})
|
||||
}
|
||||
|
||||
func (e *testEnv) do(method, path string, headers ...string) *httptest.ResponseRecorder {
|
||||
return e.doBody(method, path, "", headers...)
|
||||
}
|
||||
|
||||
func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest.ResponseRecorder {
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// blockSync replaces the sync service with one that keeps running until the returned function is called.
|
||||
func (e *testEnv) blockSync() (release func()) {
|
||||
ch := make(chan struct{})
|
||||
e.server.sync = backend.NewSyncService(e.store, e.cfg.MusicPath, func(context.Context) { <-ch })
|
||||
e.handler = e.server.RegisterRoutes()
|
||||
return func() { close(ch); e.server.sync.Wait() }
|
||||
}
|
||||
|
||||
func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} {
|
||||
// GET falls through to the frontend's static files, which answer 404.
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
|
||||
}
|
||||
}
|
||||
assert.False(t, e.server.sync.IsSyncing(), "no sync was started")
|
||||
assert.Len(t, e.store.Songs, 1, "nothing was deleted")
|
||||
}
|
||||
|
||||
func TestSyncStartAndLock(t *testing.T) {
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
t.Run("prefix="+prefix, func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
release := e.blockSync()
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync")
|
||||
|
||||
progress := e.do(http.MethodGet, prefix+"/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, progress.Code)
|
||||
assert.Contains(t, progress.Body.String(), `"progress"`)
|
||||
|
||||
release()
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code)
|
||||
result := e.do(http.MethodGet, prefix+"/sync/progress")
|
||||
assert.Contains(t, result.Body.String(), `"total_time"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResetEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/sync/reset").Code)
|
||||
assert.Empty(t, e.store.Soundtracks)
|
||||
assert.Empty(t, e.store.Songs)
|
||||
}
|
||||
|
||||
func TestLegacyRoutesAreDeprecated(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/version")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "true", rec.Header().Get("Deprecation"))
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/version")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, rec.Header().Get("Deprecation"))
|
||||
}
|
||||
|
||||
func TestVersionEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
var latest backend.VersionData
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/version").Body.Bytes(), &latest))
|
||||
assert.Equal(t, backend.GetLatestVersion(), latest)
|
||||
|
||||
var history []backend.VersionData
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version/history").Body.Bytes(), &history))
|
||||
assert.Equal(t, backend.GetVersionHistory(), history)
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/health")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOpenAPIAndSwagger(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
for _, path := range []string{"/openapi", "/swagger/doc.json"} {
|
||||
rec := e.do(http.MethodGet, path)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
var spec struct {
|
||||
Paths map[string]map[string]any `json:"paths"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path)
|
||||
assert.Contains(t, spec.Paths, "/api/v1/token", path)
|
||||
assert.Contains(t, spec.Paths["/sync/reset"], "post", path)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code)
|
||||
}
|
||||
|
||||
func TestSearchPageAndFrontend(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Kingdom Hearts", "Dearly Beloved", "x")
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/search").Code)
|
||||
rec := e.do(http.MethodPost, "/find")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "Kingdom Hearts")
|
||||
|
||||
rec = e.do(http.MethodGet, "/")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "frontend")
|
||||
}
|
||||
|
||||
func TestShutdownWaitsForRequestsBeforeClosingDatabase(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var requestDone atomic.Bool
|
||||
e.server.httpServer.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-release
|
||||
requestDone.Store(true)
|
||||
})
|
||||
var closedAfterRequest atomic.Bool
|
||||
e.db.onClose = func() { closedAfterRequest.Store(requestDone.Load()) }
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go e.server.httpServer.Serve(ln)
|
||||
|
||||
responded := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := http.Get("http://" + ln.Addr().String())
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
responded <- err
|
||||
}()
|
||||
<-started
|
||||
|
||||
shutdownDone := make(chan error, 1)
|
||||
go func() { shutdownDone <- e.server.Shutdown(context.Background()) }()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.False(t, e.db.closed.Load(), "database must stay open while a request is running")
|
||||
|
||||
close(release)
|
||||
require.NoError(t, <-responded)
|
||||
require.NoError(t, <-shutdownDone)
|
||||
assert.True(t, e.db.closed.Load())
|
||||
assert.True(t, closedAfterRequest.Load())
|
||||
}
|
||||
|
||||
func TestTrustedProxies(t *testing.T) {
|
||||
// Without trusted proxies the forwarded header is ignored, so all requests share one bucket.
|
||||
e := newTestEnv(t)
|
||||
for i := range 10 {
|
||||
e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0."+string(rune('0'+i)))
|
||||
}
|
||||
assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.1.1").Code)
|
||||
|
||||
// httptest requests come from 192.0.2.1, which is trusted here.
|
||||
e = newTestEnv(t, func(c *config.Config) {
|
||||
_, trusted, _ := net.ParseCIDR("192.0.2.0/24")
|
||||
c.TrustedProxies = []*net.IPNet{trusted}
|
||||
})
|
||||
for range 10 {
|
||||
e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1")
|
||||
}
|
||||
assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1").Code)
|
||||
assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.2").Code)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Package fakedb provides an in-memory repository.Querier for tests.
|
||||
package fakedb
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Store is an in-memory implementation of the queries used by the application.
|
||||
// Calling a query it does not implement panics through the nil embedded interface.
|
||||
type Store struct {
|
||||
repository.Querier
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int32
|
||||
Soundtracks []repository.Soundtrack
|
||||
Songs []repository.Song
|
||||
Sessions map[string]repository.Session
|
||||
|
||||
// Err, when set, is returned by every implemented query.
|
||||
Err error
|
||||
}
|
||||
|
||||
// New returns an empty Store.
|
||||
func New() *Store {
|
||||
return &Store{Sessions: map[string]repository.Session{}}
|
||||
}
|
||||
|
||||
func (s *Store) lock() func() {
|
||||
s.mu.Lock()
|
||||
return s.mu.Unlock
|
||||
}
|
||||
|
||||
// AddSoundtrack inserts a soundtrack and returns it with its ID and UUID set.
|
||||
func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack {
|
||||
defer s.lock()()
|
||||
s.nextID++
|
||||
st.ID = s.nextID
|
||||
if st.Uuid == uuid.Nil {
|
||||
st.Uuid = uuid.New()
|
||||
}
|
||||
s.Soundtracks = append(s.Soundtracks, st)
|
||||
return st
|
||||
}
|
||||
|
||||
// AddTestSong inserts a song and returns it with its UUID set.
|
||||
func (s *Store) AddTestSong(song repository.Song) repository.Song {
|
||||
defer s.lock()()
|
||||
if song.Uuid == uuid.Nil {
|
||||
song.Uuid = uuid.New()
|
||||
}
|
||||
s.Songs = append(s.Songs, song)
|
||||
return song
|
||||
}
|
||||
|
||||
// Soundtrack returns the soundtrack with the given name.
|
||||
func (s *Store) Soundtrack(name string) (repository.Soundtrack, bool) {
|
||||
defer s.lock()()
|
||||
for _, st := range s.Soundtracks {
|
||||
if st.SoundtrackName == name {
|
||||
return st, true
|
||||
}
|
||||
}
|
||||
return repository.Soundtrack{}, false
|
||||
}
|
||||
|
||||
// SongsOf returns the songs of a soundtrack sorted by path.
|
||||
func (s *Store) SongsOf(soundtrackID int32) []repository.Song {
|
||||
defer s.lock()()
|
||||
var songs []repository.Song
|
||||
for _, song := range s.Songs {
|
||||
if song.SoundtrackID == soundtrackID {
|
||||
songs = append(songs, song)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.Path, b.Path) })
|
||||
return songs
|
||||
}
|
||||
|
||||
func (s *Store) soundtrackIndex(id int32) int {
|
||||
return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.ID == id })
|
||||
}
|
||||
|
||||
func (s *Store) updateSoundtrack(id int32, f func(*repository.Soundtrack)) error {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return s.Err
|
||||
}
|
||||
if i := s.soundtrackIndex(id); i >= 0 {
|
||||
f(&s.Soundtracks[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedByName(soundtracks []repository.Soundtrack) []repository.Soundtrack {
|
||||
slices.SortFunc(soundtracks, func(a, b repository.Soundtrack) int { return cmp.Compare(a.SoundtrackName, b.SoundtrackName) })
|
||||
return soundtracks
|
||||
}
|
||||
|
||||
func (s *Store) FindAllSoundtracks(context.Context) ([]repository.Soundtrack, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
var active []repository.Soundtrack
|
||||
for _, st := range s.Soundtracks {
|
||||
if st.Deleted == nil {
|
||||
active = append(active, st)
|
||||
}
|
||||
}
|
||||
return sortedByName(active), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllSoundtracksIncludingDeleted(context.Context) ([]repository.Soundtrack, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
return sortedByName(slices.Clone(s.Soundtracks)), nil
|
||||
}
|
||||
|
||||
func (s *Store) InsertSoundtrack(_ context.Context, arg repository.InsertSoundtrackParams) (repository.InsertSoundtrackRow, error) {
|
||||
if s.Err != nil {
|
||||
return repository.InsertSoundtrackRow{}, s.Err
|
||||
}
|
||||
st := s.AddSoundtrack(repository.Soundtrack{
|
||||
SoundtrackName: arg.SoundtrackName,
|
||||
Path: arg.Path,
|
||||
Hash: arg.Hash,
|
||||
Uuid: arg.Uuid,
|
||||
Added: time.Now(),
|
||||
})
|
||||
return repository.InsertSoundtrackRow{ID: st.ID, Uuid: st.Uuid}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ResetSoundtrackIdSeq(context.Context) (int64, error) {
|
||||
return int64(s.nextID), s.Err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSoundtrackHash(_ context.Context, arg repository.UpdateSoundtrackHashParams) error {
|
||||
return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Hash = arg.Hash })
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSoundtrackName(_ context.Context, arg repository.UpdateSoundtrackNameParams) error {
|
||||
return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.SoundtrackName, st.Path = arg.Name, arg.Path })
|
||||
}
|
||||
|
||||
func (s *Store) SetSoundtrackFingerprint(_ context.Context, arg repository.SetSoundtrackFingerprintParams) error {
|
||||
return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Fingerprint = arg.Fingerprint })
|
||||
}
|
||||
|
||||
func (s *Store) RemoveSoundtrackDeletionDate(_ context.Context, id int32) error {
|
||||
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.Deleted = nil })
|
||||
}
|
||||
|
||||
func (s *Store) AddSoundtrackPlayed(_ context.Context, id int32) error {
|
||||
now := time.Now()
|
||||
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.TimesPlayed++; st.LastPlayed = &now })
|
||||
}
|
||||
|
||||
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return s.Err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range s.Soundtracks {
|
||||
if s.Soundtracks[i].Deleted == nil && !slices.Contains(ids, s.Soundtracks[i].ID) {
|
||||
s.Soundtracks[i].Deleted = &now
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearSoundtracks(context.Context) error {
|
||||
defer s.lock()()
|
||||
s.Soundtracks = nil
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) ClearSongs(context.Context) error {
|
||||
defer s.lock()()
|
||||
s.Songs = nil
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) FindSongsFromSoundtrack(_ context.Context, soundtrackID int32) ([]repository.Song, error) {
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
return s.SongsOf(soundtrackID), nil
|
||||
}
|
||||
|
||||
func (s *Store) FetchAllSongs(context.Context) ([]repository.Song, error) {
|
||||
defer s.lock()()
|
||||
return slices.Clone(s.Songs), s.Err
|
||||
}
|
||||
|
||||
func (s *Store) AddSong(_ context.Context, arg repository.AddSongParams) error {
|
||||
if s.Err != nil {
|
||||
return s.Err
|
||||
}
|
||||
s.AddTestSong(repository.Song{
|
||||
SoundtrackID: arg.SoundtrackID,
|
||||
SongName: arg.SongName,
|
||||
Path: arg.Path,
|
||||
FileName: arg.FileName,
|
||||
Hash: arg.Hash,
|
||||
Uuid: arg.Uuid,
|
||||
SoundtrackUuid: arg.SoundtrackUuid,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSongByUUID(_ context.Context, arg repository.UpdateSongByUUIDParams) error {
|
||||
defer s.lock()()
|
||||
for i := range s.Songs {
|
||||
if s.Songs[i].Uuid == arg.Uuid {
|
||||
s.Songs[i].SongName, s.Songs[i].FileName, s.Songs[i].Path, s.Songs[i].Hash = arg.SongName, arg.FileName, arg.Path, arg.Hash
|
||||
}
|
||||
}
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) AddSongPlayed(_ context.Context, arg repository.AddSongPlayedParams) error {
|
||||
defer s.lock()()
|
||||
for i := range s.Songs {
|
||||
if s.Songs[i].SoundtrackID == arg.SoundtrackID && s.Songs[i].SongName == arg.SongName {
|
||||
s.Songs[i].TimesPlayed++
|
||||
}
|
||||
}
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveBrokenSong(_ context.Context, arg repository.RemoveBrokenSongParams) error {
|
||||
defer s.lock()()
|
||||
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool {
|
||||
return song.SoundtrackID == arg.SoundtrackID && song.Path == arg.Path
|
||||
})
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool { return slices.Contains(uuids, song.Uuid) })
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetRandomActiveSong(context.Context) (repository.GetRandomActiveSongRow, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.GetRandomActiveSongRow{}, s.Err
|
||||
}
|
||||
var rows []repository.GetRandomActiveSongRow
|
||||
for _, song := range s.Songs {
|
||||
if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil {
|
||||
rows = append(rows, repository.GetRandomActiveSongRow{Song: song, SoundtrackPath: s.Soundtracks[i].Path})
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return repository.GetRandomActiveSongRow{}, pgx.ErrNoRows
|
||||
}
|
||||
return rows[rand.IntN(len(rows))], nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(_ context.Context, arg repository.CreateSessionParams) (repository.Session, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Session{}, s.Err
|
||||
}
|
||||
session := repository.Session{
|
||||
Token: arg.Token,
|
||||
IpAddress: arg.IpAddress,
|
||||
UserAgent: arg.UserAgent,
|
||||
ClientType: arg.ClientType,
|
||||
ExpiresAt: arg.ExpiresAt,
|
||||
}
|
||||
s.Sessions[arg.Token] = session
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSession(_ context.Context, token string) (repository.Session, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Session{}, s.Err
|
||||
}
|
||||
session, ok := s.Sessions[token]
|
||||
if !ok {
|
||||
return repository.Session{}, pgx.ErrNoRows
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.lock()()
|
||||
delete(s.Sessions, token)
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExpiredSessions(context.Context) error {
|
||||
defer s.lock()()
|
||||
for token, session := range s.Sessions {
|
||||
if session.ExpiresAt.Time.Before(time.Now()) {
|
||||
delete(s.Sessions, token)
|
||||
}
|
||||
}
|
||||
return s.Err
|
||||
}
|
||||
|
||||
// HasSession reports whether a session with the given token exists.
|
||||
func (s *Store) HasSession(token string) bool {
|
||||
defer s.lock()()
|
||||
_, ok := s.Sessions[token]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//go:build integration
|
||||
|
||||
// Package pgtest starts a PostgreSQL container for integration tests.
|
||||
//
|
||||
// Use it from TestMain:
|
||||
//
|
||||
// func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
)
|
||||
|
||||
var (
|
||||
container testcontainers.Container
|
||||
host string
|
||||
port string
|
||||
counter atomic.Int32
|
||||
)
|
||||
|
||||
// Main starts PostgreSQL, runs the tests and stops PostgreSQL again.
|
||||
func Main(m *testing.M) int {
|
||||
ctx := context.Background()
|
||||
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: testcontainers.ContainerRequest{
|
||||
Image: "postgres:15-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{"POSTGRES_USER": user, "POSTGRES_PASSWORD": password},
|
||||
WaitingFor: wait.ForAll(
|
||||
wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||
wait.ForListeningPort("5432/tcp"),
|
||||
).WithDeadline(2 * time.Minute),
|
||||
},
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("failed to start PostgreSQL container (is Docker running?): %v", err)
|
||||
return 1
|
||||
}
|
||||
defer func() {
|
||||
if err := c.Terminate(ctx); err != nil {
|
||||
log.Printf("failed to stop PostgreSQL container: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
container = c
|
||||
host, err = c.Host(ctx)
|
||||
if err != nil {
|
||||
log.Printf("failed to get container host: %v", err)
|
||||
return 1
|
||||
}
|
||||
mapped, err := c.MappedPort(ctx, "5432/tcp")
|
||||
if err != nil {
|
||||
log.Printf("failed to get container port: %v", err)
|
||||
return 1
|
||||
}
|
||||
port = mapped.Port()
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
// ConnString returns the connection string for a database in the container.
|
||||
func ConnString(dbname string) string {
|
||||
return db.ConnString(host, port, user, password, dbname)
|
||||
}
|
||||
|
||||
// NewDatabase creates an empty database and returns its name. It is dropped when the test ends.
|
||||
func NewDatabase(t *testing.T) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
name := fmt.Sprintf("test_%d_%d", time.Now().UnixNano()%1_000_000, counter.Add(1))
|
||||
|
||||
admin, err := pgxpool.New(ctx, ConnString("postgres"))
|
||||
if err != nil {
|
||||
t.Fatalf("connect to postgres: %v", err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil {
|
||||
t.Fatalf("create database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin, err := pgxpool.New(ctx, ConnString("postgres"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer admin.Close()
|
||||
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)")
|
||||
})
|
||||
return name
|
||||
}
|
||||
|
||||
// NewMigratedDatabase creates a database with all migrations applied.
|
||||
func NewMigratedDatabase(t *testing.T) *db.Database {
|
||||
t.Helper()
|
||||
database, err := db.NewDatabase(context.Background(), ConnString(NewDatabase(t)))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(database.Close)
|
||||
if err := database.RunMigrations(); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
// LoadSQLFile runs a SQL file with psql inside the container, as the superuser.
|
||||
func LoadSQLFile(t *testing.T, dbname, path string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
target := "/tmp/" + dbname + ".sql"
|
||||
if err := container.CopyFileToContainer(ctx, path, target, 0o644); err != nil {
|
||||
t.Fatalf("copy %s to container: %v", path, err)
|
||||
}
|
||||
code, reader, err := container.Exec(ctx, []string{"psql", "-U", user, "-d", dbname, "-v", "ON_ERROR_STOP=1", "-q", "-f", target})
|
||||
if err != nil {
|
||||
t.Fatalf("run psql: %v", err)
|
||||
}
|
||||
output, _ := io.ReadAll(reader)
|
||||
if code != 0 {
|
||||
t.Fatalf("psql exited with %d: %s", code, output)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRole creates a login role, used by dumps that reference their original owner.
|
||||
func CreateRole(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, ConnString("postgres"))
|
||||
if err != nil {
|
||||
t.Fatalf("connect to postgres: %v", err)
|
||||
}
|
||||
defer admin.Close()
|
||||
_, err = admin.Exec(ctx, "CREATE ROLE "+name+" LOGIN")
|
||||
if err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user