Rewrite tests to run without a database

- Unit tests use an in-memory fake database (internal/testutil/fakedb),
  temp folders and httptest; `go test ./...` needs no Docker or env vars
- Integration tests (build tag "integration") start PostgreSQL with
  testcontainers: migrations step by step, SQL dumps (skipped if
  missing) and a full sync against a real database
- Removed tests that only checked copies of the logic or had empty
  bodies, the broken root integration_test.go, compose.test.yaml and
  .env.test with hardcoded paths
- testMusic MP3s replaced with tiny placeholders (sync never decodes
  audio), 194 MB -> 168 KB
This commit is contained in:
Sansan committed 2026-10-06 15:20:31 +02:00
1 parent 090e794460
commit 7b144ae419
42 files changed
+2334 -40

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
# Test Database Configuration
DB_HOST=localhost
DB_PORT=5433
DB_USERNAME=testuser
DB_PASSWORD=testpass
DB_NAME=music_server_test
# Test Paths
MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic
CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters
# Server Configuration
PORT=8081
LOG_LEVEL=debug
LOG_JSON=false
# Session Authentication (for testing)
# Set to disable session auth during development
DISABLE_SESSION_AUTH=true
+100
View File
@@ -0,0 +1,100 @@
package web
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"music-server/internal/backend"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var soundtracks = []string{
"Call of Duty: Modern Warfare",
"Final Fantasy VII",
"Joe & Mac",
"Kingdom Hearts",
"Lemmings (SNES + Mega Drive)",
"MG",
"Metal Gear Solid 4 - Guns of the Patriots",
"Pokémon HeartGold & SoulSilver",
"Resident Evil 2 (Remake)",
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
}
func newCache(names []string) *backend.SearchCache {
return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil })
}
func searchData(t *testing.T) []backend.SoundtrackData {
data, err := newCache(soundtracks).Get(context.Background())
require.NoError(t, err)
return data
}
func TestSearch(t *testing.T) {
data := searchData(t)
tests := []struct {
query string
want []string
}{
// Exact (case-insensitive) substring
{"resident", []string{"Resident Evil 2 (Remake)"}},
// Ignoring spaces and special characters
{"joeandmac", []string{"Joe & Mac"}},
{"snes+mega", []string{"Lemmings (SNES + Mega Drive)"}},
{"pokemon", []string{"Pokémon HeartGold & SoulSilver"}},
// Letters in order
{"codmw", []string{"Call of Duty: Modern Warfare"}},
{"mgs4gotp", []string{"Metal Gear Solid 4 - Guns of the Patriots"}},
{"re2r", []string{"Resident Evil 2 (Remake)"}},
// No match
{"zzz", nil},
// Regex special characters are plain text
{"(remake)", []string{"Resident Evil 2 (Remake)"}},
{"[", nil},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
assert.Equal(t, tt.want, search(tt.query, data))
})
}
}
func TestSearchEmptyReturnsEverything(t *testing.T) {
assert.Equal(t, soundtracks, search("", searchData(t)))
}
func TestSearchOrdering(t *testing.T) {
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order.
results := search("mg", searchData(t))
require.GreaterOrEqual(t, len(results), 2)
assert.Equal(t, "MG", results[0])
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots")
}
func TestSearchHandler(t *testing.T) {
handler := NewSearchHandler(newCache(soundtracks))
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Kingdom Hearts")
assert.NotContains(t, rec.Body.String(), "Final Fantasy")
}
func TestSearchHandlerLoadError(t *testing.T) {
cache := backend.NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
rec := httptest.NewRecorder()
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
-21
View File
@@ -1,21 +0,0 @@
version: '3.8'
services:
test-db:
image: postgres:15-alpine
environment:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: music_server_test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- test-db-data:/var/lib/postgresql/data
volumes:
test-db-data:
+58
View File
@@ -0,0 +1,58 @@
package backend
import (
"io"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newCharacterService(t *testing.T) *CharacterService {
t.Helper()
base := t.TempDir()
writeFile(t, filepath.Join(base, "secret.png"), "outside")
dir := filepath.Join(base, "characters")
writeFile(t, filepath.Join(dir, "Adol.png"), "adol")
writeFile(t, filepath.Join(dir, "Aloy.JPG"), "aloy")
writeFile(t, filepath.Join(dir, "notes.txt"), "text")
writeFile(t, filepath.Join(dir, "sub", "Hidden.png"), "hidden")
c, err := NewCharacterService(dir)
require.NoError(t, err)
t.Cleanup(func() { c.Close() })
return c
}
func TestCharacterList(t *testing.T) {
characters, err := newCharacterService(t).List()
require.NoError(t, err)
assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters)
}
func TestCharacterOpen(t *testing.T) {
c := newCharacterService(t)
f, err := c.Open("Adol.png")
require.NoError(t, err)
content, _ := io.ReadAll(f)
f.Close()
assert.Equal(t, "adol", string(content))
_, err = c.Open("Missing.png")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestCharacterOpenRejectsPathTraversal(t *testing.T) {
c := newCharacterService(t)
for _, name := range []string{"", "../secret.png", "sub/Hidden.png", "/etc/passwd", "..", "notes.txt"} {
_, err := c.Open(name)
assert.ErrorIs(t, err, ErrInvalidName, name)
}
}
func TestNewCharacterServiceMissingDir(t *testing.T) {
_, err := NewCharacterService(filepath.Join(t.TempDir(), "missing"))
assert.Error(t, err)
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !integration
package backend
import (
"os"
"testing"
"music-server/internal/logging"
)
func TestMain(m *testing.M) {
logging.Init("fatal", false)
os.Exit(m.Run())
}
+224
View File
@@ -0,0 +1,224 @@
package backend
import (
"context"
"os"
"path/filepath"
"testing"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
// addSoundtrackWithSongs creates a soundtrack folder with song files and matching database rows.
func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name string, timesPlayed int32, songs ...string) repository.Soundtrack {
t.Helper()
dir := musicDir + "/" + name + "/"
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: name, Path: dir, TimesPlayed: timesPlayed})
for _, song := range songs {
fileName := song + ".mp3"
writeFile(t, dir+fileName, song)
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: song, Path: dir + fileName, FileName: &fileName})
}
return st
}
func TestMusicServiceEmptyQueue(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir())
_, err := m.NextSong()
assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.PreviousSong()
assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.Song(0)
assert.ErrorIs(t, err, ErrQueueEmpty)
assert.Equal(t, SongInfo{}, m.SongInfo(context.Background()))
assert.Empty(t, m.PlayedSongs(context.Background()))
assert.NoError(t, m.AddLatestPlayed(context.Background()))
assert.NoError(t, m.SetPlayed(context.Background(), -1))
}
func TestMusicServiceNoSoundtracks(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background()
_, err := m.RandomSong(ctx)
assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongLowChance(ctx)
assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongClassic(ctx)
assert.ErrorIs(t, err, ErrNoSongs)
}
func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) {
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"})
m := NewMusicService(store, t.TempDir())
_, err := m.RandomSong(context.Background())
assert.ErrorIs(t, err, ErrNoSongs)
}
func TestMusicServiceQueue(t *testing.T) {
store := fakedb.New()
musicDir := t.TempDir()
addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2")
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
first, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
second, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
info := m.SongInfo(ctx)
assert.Equal(t, "Game A", info.Soundtrack)
assert.Equal(t, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying)
path, err := m.PreviousSong()
require.NoError(t, err)
assert.Equal(t, first, path)
path, err = m.PreviousSong()
require.NoError(t, err)
assert.Equal(t, first, path, "previous stays on the first song")
path, err = m.NextSong()
require.NoError(t, err)
assert.Equal(t, second, path)
path, err = m.NextSong()
require.NoError(t, err)
assert.Equal(t, second, path, "next stays on the last song")
path, err = m.Song(99)
require.NoError(t, err)
assert.Equal(t, second, path, "song number is clamped")
played := m.PlayedSongs(ctx)
require.Len(t, played, 2)
assert.False(t, played[0].CurrentlyPlaying)
assert.True(t, played[1].CurrentlyPlaying)
require.NoError(t, m.Reset(ctx))
_, err = m.NextSong()
assert.ErrorIs(t, err, ErrQueueEmpty)
}
func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir())
m.AddLatestToQueue()
_, err := m.NextSong()
assert.ErrorIs(t, err, ErrQueueEmpty)
}
func TestMusicServiceMarkPlayed(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
_, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
require.NoError(t, m.AddLatestPlayed(ctx))
require.NoError(t, m.SetPlayed(ctx, 0))
require.NoError(t, m.SetPlayed(ctx, 5), "out of range is ignored")
updated, _ := store.Soundtrack("Game A")
assert.Equal(t, int32(2), updated.TimesPlayed)
assert.Equal(t, int32(2), store.SongsOf(st.ID)[0].TimesPlayed)
}
func TestMusicServiceRemovesBrokenSongs(t *testing.T) {
store := fakedb.New()
musicDir := t.TempDir()
st := addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Good")
missing := "Missing.mp3"
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing})
wav := "Wave.wav"
writeFile(t, st.Path+wav, "wav")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav})
m := NewMusicService(store, t.TempDir())
for range 20 {
path, err := m.RandomSong(context.Background())
require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path)
}
songs := store.SongsOf(st.ID)
require.Len(t, songs, 1)
assert.Equal(t, "Good", songs[0].SongName)
}
func TestMusicServiceRandomSongClassic(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Good")
missing := "Missing.mp3"
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing})
m := NewMusicService(store, t.TempDir())
for range 20 {
path, err := m.RandomSongClassic(context.Background())
require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path)
}
assert.Len(t, store.SongsOf(st.ID), 1)
}
func TestLowChanceWeights(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Often", TimesPlayed: 30},
{SoundtrackName: "Average", TimesPlayed: 20},
{SoundtrackName: "Rarely", TimesPlayed: 10},
}
weights := lowChanceWeights(soundtracks)
assert.Equal(t, []int{1, 1, 10}, []int{weights[0].weight, weights[1].weight, weights[2].weight})
}
func TestAveragePlayed(t *testing.T) {
assert.Equal(t, int32(0), averagePlayed(nil))
assert.Equal(t, int32(20), averagePlayed([]repository.Soundtrack{{TimesPlayed: 10}, {TimesPlayed: 30}}))
}
func TestSoundtrackNames(t *testing.T) {
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
m := NewMusicService(store, t.TempDir())
names, err := m.SoundtrackNames(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{"A", "B"}, names)
random, err := m.SoundtrackNamesRandom(context.Background())
require.NoError(t, err)
assert.ElementsMatch(t, []string{"A", "B"}, random)
assert.Equal(t, []string{"A", "B"}, names, "shuffling must not change the cached order")
}
func TestSoundCheckSong(t *testing.T) {
dir := t.TempDir()
m := NewMusicService(fakedb.New(), dir)
_, err := m.SoundCheckSong()
assert.ErrorIs(t, err, ErrNoSongs)
writeFile(t, filepath.Join(dir, "check.mp3"), "x")
path, err := m.SoundCheckSong()
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "check.mp3"), path)
_, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong()
assert.Error(t, err)
}
+81
View File
@@ -0,0 +1,81 @@
package backend
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newGitea(t *testing.T, status int) (*httptest.Server, *atomic.Int32) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(status)
json.NewEncoder(w).Encode(giteaRelease{
Id: 1,
Name: "v1.2.3",
Assets: []giteaAsset{
{Name: "MusicPlayer.exe", DownloadUrl: "https://example.com/MusicPlayer.exe"},
{Name: "MusicPlayer.x86_64", DownloadUrl: "https://example.com/MusicPlayer.x86_64"},
},
})
}))
t.Cleanup(server.Close)
return server, &calls
}
func TestReleaseService(t *testing.T) {
server, _ := newGitea(t, http.StatusOK)
r := NewReleaseService(server.URL)
ctx := context.Background()
version, err := r.LatestVersion(ctx)
require.NoError(t, err)
assert.Equal(t, "v1.2.3", version)
names, err := r.AssetNames(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"MusicPlayer.exe", "MusicPlayer.x86_64"}, names)
url, err := r.AssetURL(ctx, ".exe")
require.NoError(t, err)
assert.Equal(t, "https://example.com/MusicPlayer.exe", url)
_, err = r.AssetURL(ctx, ".dmg")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestReleaseServiceCachesRelease(t *testing.T) {
server, calls := newGitea(t, http.StatusOK)
r := NewReleaseService(server.URL)
now := time.Now()
r.now = func() time.Time { return now }
ctx := context.Background()
for range 3 {
_, err := r.LatestVersion(ctx)
require.NoError(t, err)
}
assert.Equal(t, int32(1), calls.Load())
now = now.Add(releaseCacheTTL + time.Second)
_, err := r.LatestVersion(ctx)
require.NoError(t, err)
assert.Equal(t, int32(2), calls.Load())
}
func TestReleaseServiceUpstreamErrors(t *testing.T) {
server, _ := newGitea(t, http.StatusInternalServerError)
_, err := NewReleaseService(server.URL).LatestVersion(context.Background())
assert.ErrorIs(t, err, ErrUpstream)
_, err = NewReleaseService("http://127.0.0.1:1").AssetNames(context.Background())
assert.ErrorIs(t, err, ErrUpstream)
}
+67
View File
@@ -0,0 +1,67 @@
package backend
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCleanSearchTerm(t *testing.T) {
tests := map[string]string{
"MGS4": "mgs4",
" test ": "test",
"Test 123": "test123",
"A+B": "aplusb",
"Mario & Luigi": "marioandluigi",
"É": "e",
"It's": "it's",
"Rock 'n Roll": "rockandroll",
"": "",
}
for input, expected := range tests {
assert.Equal(t, expected, CleanSearchTerm(input), input)
}
}
func TestMatchesSubsequence(t *testing.T) {
assert.True(t, MatchesSubsequence("", "anything"))
assert.True(t, MatchesSubsequence("ff7", "finalfantasy7"))
assert.True(t, MatchesSubsequence("lumière", "clairobscur-lumière"))
assert.False(t, MatchesSubsequence("7ff", "finalfantasy7"))
// Characters that are special in regular expressions are matched literally.
for _, term := range []string{"(", "[", "*", "\\", "a(b", ".*"} {
assert.NotPanics(t, func() { MatchesSubsequence(term, "plain text") }, term)
assert.False(t, MatchesSubsequence(term, "plain text"), term)
}
assert.True(t, MatchesSubsequence("(", "game (remake)"))
}
func TestSearchCache(t *testing.T) {
names := []string{"Final Fantasy VII"}
calls := 0
cache := NewSearchCache(func(context.Context) ([]string, error) {
calls++
return names, nil
})
ctx := context.Background()
data, err := cache.Get(ctx)
require.NoError(t, err)
assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data)
_, _ = cache.Get(ctx)
assert.Equal(t, 1, calls, "data is cached")
names = []string{"Chrono Trigger"}
require.NoError(t, cache.Reload(ctx))
data, _ = cache.Get(ctx)
assert.Equal(t, "Chrono Trigger", data[0].Original)
}
func TestSearchCacheLoadError(t *testing.T) {
cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
_, err := cache.Get(context.Background())
assert.Error(t, err)
}
+111
View File
@@ -0,0 +1,111 @@
//go:build integration
package backend
import (
"context"
"os"
"path/filepath"
"testing"
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/testutil/pgtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
logging.Init("fatal", false)
os.Exit(pgtest.Main(m))
}
// copyTestMusic copies the repository's testMusic folder so tests can change it.
func copyTestMusic(t *testing.T) string {
t.Helper()
dst := t.TempDir()
require.NoError(t, os.CopyFS(dst, os.DirFS(filepath.Join("..", "..", "testMusic"))))
return dst
}
// TestSyncWithPostgres runs syncs against a real database to check the SQL queries.
func TestSyncWithPostgres(t *testing.T) {
ctx := context.Background()
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
musicDir := copyTestMusic(t)
s := NewSyncService(q, musicDir, nil)
run := func(full bool) SyncResponse {
require.True(t, s.Start(full))
s.Wait()
result := s.Result()
require.Empty(t, result.CatchedErrors)
return result
}
result := run(false)
assert.Equal(t, []string{"A Bird Story", "Clair Obscur - Expedition 33", "Metal Gear Rising - Revengeance"}, result.SoundtracksAdded)
soundtracks, err := q.FindAllSoundtracks(ctx)
require.NoError(t, err)
require.Len(t, soundtracks, 3)
bird := soundtracks[0]
assert.NotEmpty(t, bird.Fingerprint)
songs, err := q.FindSongsFromSoundtrack(ctx, bird.ID)
require.NoError(t, err)
assert.Len(t, songs, 6)
// Play a song, then rename one of its files and, in a later sync, the soundtrack folder.
// (Renaming both at once changes the content hash, so the folder would count as new.)
require.NoError(t, q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: bird.ID, SongName: "2. Yesterday"}))
birdDir := filepath.Join(musicDir, "A Bird Story")
require.NoError(t, os.Rename(filepath.Join(birdDir, "2. Yesterday.mp3"), filepath.Join(birdDir, "02 Yesterday.mp3")))
result = run(false)
assert.Equal(t, []string{"A Bird Story"}, result.SoundtracksChangedContent)
require.NoError(t, os.Rename(birdDir, filepath.Join(musicDir, "A Bird Story (2014)")))
result = run(false)
assert.Equal(t, map[string]string{"A Bird Story": "A Bird Story (2014)"}, result.SoundtracksChangedTitle)
assert.Empty(t, result.SoundtracksAdded)
assert.Empty(t, result.SoundtracksRemoved)
renamed, err := q.GetSoundtrackById(ctx, bird.ID)
require.NoError(t, err)
assert.Equal(t, "A Bird Story (2014)", renamed.SoundtrackName)
songs, err = q.FindSongsFromSoundtrack(ctx, bird.ID)
require.NoError(t, err)
require.Len(t, songs, 6)
var yesterday repository.Song
for _, song := range songs {
assert.Contains(t, song.Path, "/A Bird Story (2014)/")
if song.SongName == "02 Yesterday" {
yesterday = song
}
}
assert.Equal(t, int32(1), yesterday.TimesPlayed, "play count survives renames")
// Nothing changed: the fingerprint lets the sync skip everything.
result = run(false)
assert.Empty(t, result.SoundtracksChangedContent)
assert.Empty(t, result.SoundtracksChangedTitle)
// Remove a soundtrack and bring it back.
mgr := filepath.Join(musicDir, "Metal Gear Rising - Revengeance")
backup := filepath.Join(t.TempDir(), "mgr")
require.NoError(t, os.Rename(mgr, backup))
result = run(false)
assert.Equal(t, []string{"Metal Gear Rising - Revengeance"}, result.SoundtracksRemoved)
active, err := q.FindAllSoundtracks(ctx)
require.NoError(t, err)
assert.Len(t, active, 2)
require.NoError(t, os.Rename(backup, mgr))
result = run(true)
assert.Equal(t, []string{"Metal Gear Rising - Revengeance"}, result.SoundtracksReAdded)
// Classic random picks only songs of active soundtracks.
row, err := q.GetRandomActiveSong(ctx)
require.NoError(t, err)
assert.NotEmpty(t, row.SoundtrackPath)
}
+398
View File
@@ -0,0 +1,398 @@
package backend
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func strPtr(s string) *string { return &s }
func TestReconcileSongs(t *testing.T) {
keep := repository.Song{Uuid: uuid.New(), Path: "/m/a.mp3", SongName: "a", FileName: strPtr("a.mp3"), Hash: "ha"}
changed := repository.Song{Uuid: uuid.New(), Path: "/m/b.mp3", SongName: "b", FileName: strPtr("b.mp3"), Hash: "old"}
renamed := repository.Song{Uuid: uuid.New(), Path: "/m/c.mp3", SongName: "c", FileName: strPtr("c.mp3"), Hash: "hc"}
removed := repository.Song{Uuid: uuid.New(), Path: "/m/d.mp3", SongName: "d", FileName: strPtr("d.mp3"), Hash: "hd"}
files := []songFile{
{name: "a", fileName: "a.mp3", path: "/m/a.mp3", hash: "ha"},
{name: "b", fileName: "b.mp3", path: "/m/b.mp3", hash: "new"},
{name: "c2", fileName: "c2.mp3", path: "/m/c2.mp3", hash: "hc"},
{name: "e", fileName: "e.mp3", path: "/m/e.mp3", hash: "he"},
}
ops := reconcileSongs([]repository.Song{keep, changed, renamed, removed}, files)
assert.Equal(t, []songFile{files[3]}, ops.insert)
assert.Equal(t, []uuid.UUID{removed.Uuid}, ops.delete)
assert.ElementsMatch(t, []songUpdate{
{uuid: changed.Uuid, file: files[1]},
{uuid: renamed.Uuid, file: files[2]},
}, ops.update)
}
func TestReconcileSongsDuplicateHashes(t *testing.T) {
// Two identical files: each existing song may only be matched once.
existing := repository.Song{Uuid: uuid.New(), Path: "/m/a.mp3", SongName: "a", FileName: strPtr("a.mp3"), Hash: "h"}
files := []songFile{
{name: "a", fileName: "a.mp3", path: "/m/a.mp3", hash: "h"},
{name: "copy", fileName: "copy.mp3", path: "/m/copy.mp3", hash: "h"},
}
ops := reconcileSongs([]repository.Song{existing}, files)
assert.Empty(t, ops.update)
assert.Empty(t, ops.delete)
assert.Equal(t, []songFile{files[1]}, ops.insert)
}
func TestClassify(t *testing.T) {
existing := []repository.Soundtrack{
{ID: 1, SoundtrackName: "Game A", Hash: "ha"},
{ID: 2, SoundtrackName: "Game B", Hash: "hb"},
}
tests := []struct {
name, hash string
status SoundtrackStatus
id int32
}{
{"Game A", "ha", NotChanged, 1},
{"Game A", "changed", SoundtrackChanged, 1},
{"Game B renamed", "hb", TitleChanged, 2},
{"Game C", "hc", NewSoundtrack, 0},
}
for _, tt := range tests {
status, match := classify(existing, tt.name, tt.hash)
assert.Equal(t, tt.status, status, tt.name)
if tt.id == 0 {
assert.Nil(t, match)
} else {
assert.Equal(t, tt.id, match.ID)
}
}
}
func TestFingerprint(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "a.mp3"), "one")
writeFile(t, filepath.Join(dir, "sub", "b.mp3"), "two")
first, err := fingerprint(dir)
require.NoError(t, err)
again, err := fingerprint(dir)
require.NoError(t, err)
assert.Equal(t, first, again)
writeFile(t, filepath.Join(dir, "sub", "b.mp3"), "changed size")
changed, err := fingerprint(dir)
require.NoError(t, err)
assert.NotEqual(t, first, changed, "changes in sub folders are detected")
// Moving the folder keeps the fingerprint, so renames are detected by name lookup failing.
moved := filepath.Join(t.TempDir(), "moved")
require.NoError(t, os.Rename(dir, moved))
afterMove, err := fingerprint(moved)
require.NoError(t, err)
assert.Equal(t, changed, afterMove)
}
func TestIsSong(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"a.mp3", "b.MP3", "c.wav", "cover.jpg"} {
writeFile(t, filepath.Join(dir, name), "x")
}
require.NoError(t, os.Mkdir(filepath.Join(dir, "folder.mp3"), 0o755))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
var songs []string
for _, e := range entries {
if isSong(e) {
songs = append(songs, e.Name())
}
}
assert.Equal(t, []string{"a.mp3", "b.MP3"}, songs)
assert.Equal(t, "b", songName("b.MP3"))
}
// syncFixture is a music folder with a SyncService backed by an in-memory database.
type syncFixture struct {
t *testing.T
dir string
store *fakedb.Store
sync *SyncService
}
func newSyncFixture(t *testing.T) *syncFixture {
dir := t.TempDir()
store := fakedb.New()
return &syncFixture{t: t, dir: dir, store: store, sync: NewSyncService(store, dir, nil)}
}
func (f *syncFixture) write(rel, content string) {
writeFile(f.t, filepath.Join(f.dir, rel), content)
}
func (f *syncFixture) run(full bool) SyncResponse {
f.t.Helper()
require.True(f.t, f.sync.Start(full))
f.sync.Wait()
require.False(f.t, f.sync.IsSyncing())
return f.sync.Result()
}
func (f *syncFixture) soundtrack(name string) repository.Soundtrack {
f.t.Helper()
st, ok := f.store.Soundtrack(name)
require.True(f.t, ok, "soundtrack %q not found", name)
return st
}
func TestSyncAddsSoundtracksAndSongs(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01 Title.mp3", "a1")
f.write("Game A/02 Battle.MP3", "a2")
f.write("Game A/cover.jpg", "img")
f.write("Game B/01 Intro.mp3", "b1")
f.write("old/ignored.mp3", "x")
f.write("loose file.mp3", "x")
result := f.run(false)
assert.Equal(t, []string{"Game A", "Game B"}, result.SoundtracksAdded)
assert.Empty(t, result.CatchedErrors)
a := f.soundtrack("Game A")
assert.Equal(t, f.dir+"/Game A/", a.Path)
assert.NotEmpty(t, a.Hash)
assert.NotEmpty(t, a.Fingerprint)
songs := f.store.SongsOf(a.ID)
require.Len(t, songs, 2)
assert.Equal(t, "01 Title", songs[0].SongName)
assert.Equal(t, "02 Battle", songs[1].SongName)
assert.Equal(t, a.Uuid, songs[0].SoundtrackUuid)
assert.NotEmpty(t, songs[0].Hash)
_, ok := f.store.Soundtrack("old")
assert.False(t, ok, "skipped folders are not synced")
}
func TestSyncSkipsUnchangedSoundtracksByFingerprint(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.run(false)
// If the folder were hashed again, the stored hash would be replaced.
a := f.soundtrack("Game A")
require.NoError(t, f.store.UpdateSoundtrackHash(context.Background(), repository.UpdateSoundtrackHashParams{Hash: "stale", ID: a.ID}))
result := f.run(false)
assert.Empty(t, result.SoundtracksAdded)
assert.Empty(t, result.SoundtracksChangedContent)
assert.Equal(t, "stale", f.soundtrack("Game A").Hash)
result = f.run(true)
assert.NotEqual(t, "stale", f.soundtrack("Game A").Hash, "a full sync always hashes")
assert.Empty(t, result.SoundtracksChangedTitle, "a full sync does not report unchanged titles")
}
func TestSyncDetectsChangedContent(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.write("Game A/02.mp3", "a2")
f.run(false)
a := f.soundtrack("Game A")
before := f.store.SongsOf(a.ID)
require.NoError(t, os.Remove(filepath.Join(f.dir, "Game A/02.mp3")))
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A/01.mp3"), filepath.Join(f.dir, "Game A/01 renamed.mp3")))
f.write("Game A/03.mp3", "a3")
result := f.run(false)
assert.Equal(t, []string{"Game A"}, result.SoundtracksChangedContent)
after := f.store.SongsOf(a.ID)
require.Len(t, after, 2)
assert.Equal(t, "01 renamed", after[0].SongName)
assert.Equal(t, before[0].Uuid, after[0].Uuid, "renamed song keeps its identity and play count")
assert.Equal(t, "03", after[1].SongName)
}
func TestSyncDetectsRenamedSoundtrack(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.run(false)
a := f.soundtrack("Game A")
require.NoError(t, f.store.AddSongPlayed(context.Background(), repository.AddSongPlayedParams{SoundtrackID: a.ID, SongName: "01"}))
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A"), filepath.Join(f.dir, "Game A Remastered")))
result := f.run(false)
assert.Equal(t, map[string]string{"Game A": "Game A Remastered"}, result.SoundtracksChangedTitle)
assert.Empty(t, result.SoundtracksRemoved)
assert.Empty(t, result.SoundtracksAdded)
renamed := f.soundtrack("Game A Remastered")
assert.Equal(t, a.ID, renamed.ID)
assert.Equal(t, f.dir+"/Game A Remastered/", renamed.Path)
songs := f.store.SongsOf(a.ID)
require.Len(t, songs, 1)
assert.Equal(t, f.dir+"/Game A Remastered/01.mp3", songs[0].Path)
assert.Equal(t, int32(1), songs[0].TimesPlayed)
}
func TestSyncRemovedAndReAdded(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.write("Game B/01.mp3", "b1")
f.run(false)
backup := filepath.Join(t.TempDir(), "Game B")
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game B"), backup))
result := f.run(false)
assert.Equal(t, []string{"Game B"}, result.SoundtracksRemoved)
assert.NotNil(t, f.soundtrack("Game B").Deleted)
// Polling the result must not change it.
assert.Equal(t, result, f.sync.Result())
assert.Equal(t, result, f.sync.Result())
require.NoError(t, os.Rename(backup, filepath.Join(f.dir, "Game B")))
result = f.run(false)
assert.Equal(t, []string{"Game B"}, result.SoundtracksReAdded)
assert.Empty(t, result.SoundtracksRemoved)
assert.Nil(t, f.soundtrack("Game B").Deleted)
// A full sync reports re-added soundtracks too.
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game B"), backup))
f.run(false)
require.NoError(t, os.Rename(backup, filepath.Join(f.dir, "Game B")))
result = f.run(true)
assert.Equal(t, []string{"Game B"}, result.SoundtracksReAdded)
assert.Nil(t, f.soundtrack("Game B").Deleted)
}
func TestSyncUpdatesMovedMusicPath(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.run(false)
newDir := filepath.Join(t.TempDir(), "music")
require.NoError(t, os.Rename(f.dir, newDir))
f.dir = newDir
f.sync = NewSyncService(f.store, newDir, nil)
f.run(false)
a := f.soundtrack("Game A")
assert.Equal(t, newDir+"/Game A/", a.Path)
assert.Equal(t, newDir+"/Game A/01.mp3", f.store.SongsOf(a.ID)[0].Path)
}
func TestSyncIDFileCleanup(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.write("Game A/.5.id", "")
f.write("Game B/01.mp3", "b1")
f.write("Game B/.6.id", "")
// Simulate an upgraded database: hashes are known but no fingerprints yet.
for _, name := range []string{"Game A", "Game B"} {
hash, err := dirHash(filepath.Join(f.dir, name) + "/")
require.NoError(t, err)
f.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: name, Path: f.dir + "/" + name + "/", Hash: hash})
}
f.write("Game B/02.mp3", "b2") // Game B changes, Game A does not
f.run(false)
assert.FileExists(t, filepath.Join(f.dir, "Game A/.5.id"), "unchanged soundtracks keep their .id files")
assert.NoFileExists(t, filepath.Join(f.dir, "Game B/.6.id"), "changed soundtracks are cleaned up")
result := f.run(true)
assert.NoFileExists(t, filepath.Join(f.dir, "Game A/.5.id"), "a full sync cleans up everything")
assert.Empty(t, result.CatchedErrors)
// The stored fingerprint matches the folder without .id files, so the next sync skips it.
a := f.soundtrack("Game A")
fp, err := fingerprint(a.Path)
require.NoError(t, err)
assert.Equal(t, fp, a.Fingerprint)
}
func TestSyncFullRemovesBrokenSongs(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.run(false)
gone := f.store.AddTestSong(repository.Song{SoundtrackID: 999, SongName: "gone", Path: "/does/not/exist.mp3"})
f.run(false)
assert.Len(t, f.store.Songs, 2, "changes-only sync leaves other songs alone")
f.run(true)
for _, song := range f.store.Songs {
assert.NotEqual(t, gone.Uuid, song.Uuid)
}
}
func TestSyncUnreadableMusicPath(t *testing.T) {
store := fakedb.New()
existing := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game A"})
s := NewSyncService(store, filepath.Join(t.TempDir(), "missing"), nil)
require.True(t, s.Start(false))
s.Wait()
result := s.Result()
require.Len(t, result.CatchedErrors, 1)
assert.Contains(t, result.CatchedErrors[0], "ReadDir")
st, _ := store.Soundtrack(existing.SoundtrackName)
assert.Nil(t, st.Deleted, "nothing is marked deleted when the music path cannot be read")
}
func TestSyncStartOnlyOnce(t *testing.T) {
release := make(chan struct{})
s := NewSyncService(fakedb.New(), t.TempDir(), func(context.Context) { <-release })
require.True(t, s.Start(false))
assert.True(t, s.IsSyncing())
assert.False(t, s.Start(true), "a second sync cannot start while one is running")
close(release)
s.Wait()
assert.False(t, s.IsSyncing())
assert.True(t, s.Start(false))
s.Wait()
}
func TestSyncProgressBeforeFirstSync(t *testing.T) {
s := NewSyncService(fakedb.New(), t.TempDir(), nil)
assert.Equal(t, "0", s.Progress().Progress)
assert.Equal(t, SyncResponse{}, s.Result())
}
func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) {
var syncingDuringCallback bool
var s *SyncService
s = NewSyncService(fakedb.New(), t.TempDir(), func(context.Context) { syncingDuringCallback = s.IsSyncing() })
require.True(t, s.Start(false))
s.Wait()
assert.True(t, syncingDuringCallback, "caches are refreshed before requests are let through again")
}
func TestResetDB(t *testing.T) {
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
store.AddTestSong(repository.Song{SongName: "a"})
s := NewSyncService(store, t.TempDir(), nil)
require.NoError(t, s.ResetDB(context.Background()))
assert.Empty(t, store.Soundtracks)
assert.Empty(t, store.Songs)
}
func TestFormatDuration(t *testing.T) {
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second))
}
+64
View File
@@ -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")
}
+16
View File
@@ -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))
}
+86
View File
@@ -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"])
}
+39
View File
@@ -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"))
})
}
}
+283
View File
@@ -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)
}
+120
View File
@@ -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
}
+191
View File
@@ -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)
}
+327
View File
@@ -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
}
+154
View File
@@ -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)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.