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
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user