Files
MusicServer/internal/server/handlers_test.go
T
SansanandClaude Opus 5.5 bb8de5548d
CI / docker (push) Failing after 22s
CI / integration (push) Successful in 26s
CI / test (push) Successful in 26s
CI / publish (push) Skipped
Add alternative names and cover art for soundtracks
Alternative names (#32):
- PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's
  alternative names (trimmed, deduplicated, without the soundtrack's own name).
- alternative_names is in the soundtrack and statistics responses.
- /find matches them too and shows which name matched.

Covers (#12), stored in COVERS_PATH and off when it is not set:
- GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and
  PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID.
  Uploaded and picked covers are never replaced automatically.
- With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then
  AniList, by name, alternative names and the name without bracketed parts,
  at startup, after a sync and after names change. Search results are ranked
  by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV".
- cover_modified (Unix seconds) is in the soundtrack and statistics responses,
  so clients can cache covers.

The new columns are added to migration 000006, which is not released yet.

Closes #12, closes #32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:57:18 +02:00

395 lines
16 KiB
Go

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/google/uuid"
"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, "/api/v1/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, "/api/v1/music/addQue").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addPlayed").Code)
var info backend.SongInfo
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
assert.Equal(t, "Game A", info.Soundtrack)
assert.Equal(t, "Song", info.Song)
for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/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, "/api/v1/music/played?song=0").Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/api/v1/music/played?song=x").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/reset").Code)
}
func TestMusicQueueID(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "x")
rec := e.do(http.MethodGet, "/api/v1/music/rand")
require.Equal(t, http.StatusOK, rec.Code)
first := rec.Header().Get("X-Queue-Id")
require.NotEmpty(t, first, "a request without a queue ID gets a new queue")
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
rec = e.do(http.MethodGet, "/api/v1/music/list")
assert.Equal(t, first, rec.Header().Get("X-Queue-Id"), "the sent queue is kept")
assert.Contains(t, rec.Body.String(), `"Song":"Song"`)
// Another client has its own queue.
other := e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", "")
assert.NotEqual(t, first, other.Header().Get("X-Queue-Id"))
assert.JSONEq(t, "[]", other.Body.String())
e.queueID = first
rec = e.do(http.MethodGet, "/api/v1/music/reset")
require.Equal(t, http.StatusOK, rec.Code)
second := rec.Header().Get("X-Queue-Id")
assert.NotEqual(t, first, second, "reset starts a new queue")
assert.JSONEq(t, "[]", e.do(http.MethodGet, "/api/v1/music/list").Body.String())
// The old ID has ended, so it gets a new queue too.
rec = e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", first)
assert.NotEqual(t, first, rec.Header().Get("X-Queue-Id"))
assert.Empty(t, e.do(http.MethodGet, "/api/v1/music/all", "X-Queue-Id", "").Header().Get("X-Queue-Id"),
"endpoints that do not use the queue do not start one")
}
func TestMusicQueueEndsAfterSync(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "x")
e.do(http.MethodGet, "/api/v1/music/rand")
e.do(http.MethodGet, "/api/v1/music/addQue")
before := e.queueID
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
e.server.sync.Wait()
rec := e.do(http.MethodGet, "/api/v1/music/list")
assert.NotEqual(t, before, rec.Header().Get("X-Queue-Id"))
assert.JSONEq(t, "[]", rec.Body.String())
}
func TestMusicSongsAreNotCached(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "x")
e.do(http.MethodGet, "/api/v1/music/rand")
e.do(http.MethodGet, "/api/v1/music/addQue")
for _, path := range []string{"/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic",
"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/soundTest"} {
rec := e.do(http.MethodGet, path)
require.Equal(t, http.StatusOK, rec.Code, path)
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), path)
}
}
func TestMusicRangeRequest(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789")
rec := e.do(http.MethodGet, "/api/v1/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{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic"} {
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
}
// Invalid or missing parameters fail validation.
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music?song=abc").Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music").Code)
rec := e.do(http.MethodGet, "/api/v1/music/list")
assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, "[]", rec.Body.String())
}
func TestMusicSoundCheck(t *testing.T) {
e := newTestEnv(t)
rec := e.do(http.MethodGet, "/api/v1/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, "/api/v1/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, "/api/v1/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, "/api/v1/music/rand").Code)
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/all/order").Code)
}
func TestCharacterEndpoints(t *testing.T) {
e := newTestEnv(t)
var list []string
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)
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)
var details []struct {
Name string `json:"name"`
Modified int64 `json:"modified"`
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details))
require.Len(t, details, 2)
assert.Equal(t, "Adol.png", details[0].Name)
assert.Equal(t, "Aloy.jpg", details[1].Name)
assert.Positive(t, details[0].Modified)
rec := e.do(http.MethodGet, "/api/v1/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, "/api/v1/character?name=Missing.png").Code)
}
func TestCharacterPathTraversal(t *testing.T) {
e := newTestEnv(t)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required")
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} {
rec := e.do(http.MethodGet, "/api/v1/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, "/api/v1/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, "/api/v1/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, "/api/v1/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{"/api/v1/download", "/api/v1/download/list", "/api/v1/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.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")
}
var (
statsSoundtrackUUID = uuid.New()
statsSongUUID = uuid.New()
)
// 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),
SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A",
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
}}, s.err
}
func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) {
return []repository.GetAllSoundtracksWithSongsRow{{
SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A",
AlternativeNames: []string{"GA"},
CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true},
SoundtrackPlayed: 4,
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
}, {
SoundtrackName: "Game B",
}}, s.err
}
func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
}
func statsService() *backend.StatisticsService {
return backend.NewStatisticsService(statsStore{})
}
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)
assert.Equal(t, statsSoundtrackUUID, soundtracks[0].SoundtrackUuid)
assert.Equal(t, statsSongUUID, soundtracks[0].Songs[0].SongUuid)
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.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Body.Bytes(), &soundtracks))
require.Len(t, soundtracks, 2, "every soundtrack, no limit")
assert.Equal(t, "Game A", soundtracks[0].SoundtrackName)
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list")
assert.Equal(t, []string{"GA"}, soundtracks[0].AlternativeNames)
require.NotNil(t, soundtracks[0].CoverModified)
assert.Equal(t, int64(1730000000), *soundtracks[0].CoverModified)
assert.Equal(t, []string{}, soundtracks[1].AlternativeNames, "no names is an empty list")
assert.Nil(t, soundtracks[1].CoverModified)
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)
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Code)
}