Files
MusicServer/internal/server/handlers_test.go
T
Sansan 7b144ae419 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
2026-10-06 15:20:31 +02:00

284 lines
11 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/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)
}