Files
MusicServer/internal/server/openapi_test.go
T
Sansan a705d7b476 Store each client's play queue in the database
The queue lived in memory and was shared by all clients, so a restart or a
finished sync cleared it. Every client now has its own queue in the new
queue and queue_song tables (added to the undeployed migration 000006).

The music endpoints return the queue ID in the X-Queue-Id header and expect
it back. A request without an ID, or with an ended one, starts a new queue.
/music/reset ends the queue and returns a new ID, and a sync ends all
running queues. Ended queues are kept as history.

The deprecated legacy routes without the /api/v1 prefix are removed.
2026-10-07 20:03:03 +02:00

271 lines
11 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"music-server/internal/config"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/gorillamux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// loadSpec loads the OpenAPI 3.0 version of the spec the server publishes.
func loadSpec(t *testing.T, e *testEnv) *openapi3.T {
t.Helper()
rec := e.do(http.MethodGet, "/openapi-3.0.json")
require.Equal(t, http.StatusOK, rec.Code)
spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes())
require.NoError(t, err)
return spec
}
func TestOpenAPISpecIsValid(t *testing.T) {
e := newTestEnv(t)
spec := loadSpec(t, e)
require.NoError(t, spec.Validate(context.Background()))
// The 3.1 spec is valid JSON with the same paths.
var spec31 struct {
OpenAPI string `json:"openapi"`
Paths map[string]any `json:"paths"`
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31))
assert.Equal(t, "3.1.0", spec31.OpenAPI)
assert.Len(t, spec31.Paths, spec.Paths.Len())
}
func TestOpenAPISpecWithoutServerMatchesServedSpec(t *testing.T) {
served := newTestEnv(t).do(http.MethodGet, "/openapi.json").Body.Bytes()
offline, err := OpenAPISpec().MarshalJSON()
require.NoError(t, err)
assert.JSONEq(t, string(served), string(offline))
}
func TestOpenAPIDocumentsTheV1API(t *testing.T) {
spec := loadSpec(t, newTestEnv(t))
var operations []string
for path, item := range spec.Paths.Map() {
assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path)
for method, op := range item.Operations() {
operations = append(operations, method+" "+path)
public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost)
if public {
assert.Nil(t, op.Security, "%s %s needs no token", method, path)
} else {
require.NotNil(t, op.Security, "%s %s needs a token", method, path)
assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security)
assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path)
}
}
}
slices.Sort(operations)
assert.Equal(t, []string{
"DELETE /api/v1/token",
"GET /api/v1/character",
"GET /api/v1/character/list",
"GET /api/v1/download",
"GET /api/v1/download/linux",
"GET /api/v1/download/list",
"GET /api/v1/download/windows",
"GET /api/v1/health",
"GET /api/v1/music",
"GET /api/v1/music/addPlayed",
"GET /api/v1/music/addQue",
"GET /api/v1/music/all",
"GET /api/v1/music/all/order",
"GET /api/v1/music/all/random",
"GET /api/v1/music/info",
"GET /api/v1/music/list",
"GET /api/v1/music/next",
"GET /api/v1/music/previous",
"GET /api/v1/music/rand",
"GET /api/v1/music/rand/classic",
"GET /api/v1/music/rand/low",
"GET /api/v1/music/reset",
"GET /api/v1/music/soundTest",
"GET /api/v1/songs/{uuid}",
"GET /api/v1/songs/{uuid}/file",
"GET /api/v1/soundtracks",
"GET /api/v1/soundtracks/{uuid}",
"GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-played",
"GET /api/v1/statistics/soundtracks",
"GET /api/v1/statistics/soundtracks/last-played",
"GET /api/v1/statistics/soundtracks/least-played",
"GET /api/v1/statistics/soundtracks/most-played",
"GET /api/v1/statistics/soundtracks/never-played",
"GET /api/v1/statistics/soundtracks/oldest-played",
"GET /api/v1/statistics/summary",
"GET /api/v1/sync/progress",
"GET /api/v1/version",
"GET /api/v1/version/history",
"POST /api/v1/sync",
"POST /api/v1/sync/full",
"POST /api/v1/token",
"POST /api/v1/token/cleanup",
"PUT /api/v1/music/played",
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
}
// specValidator checks responses against the published spec.
type specValidator struct {
t *testing.T
e *testEnv
router routers.Router
seen map[string]bool
}
func newSpecValidator(t *testing.T, e *testEnv) *specValidator {
spec := loadSpec(t, e)
spec.Servers = openapi3.Servers{{URL: "http://example.com"}}
router, err := gorillamux.NewRouter(spec)
require.NoError(t, err)
return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}}
}
// check sends a request and validates the response status, headers and body against the spec.
func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder {
v.t.Helper()
rec := v.e.doBody(method, path, body, headers...)
require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String())
req := httptest.NewRequest(method, path, strings.NewReader(body))
route, params, err := v.router.FindRoute(req)
require.NoError(v.t, err, "%s %s is not in the spec", method, path)
v.seen[method+" "+route.Path] = true
input := &openapi3filter.ResponseValidationInput{
RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route},
Status: rec.Code,
Header: rec.Header(),
Options: &openapi3filter.Options{IncludeResponseStatus: true},
}
input.SetBodyBytes(rec.Body.Bytes())
if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") {
input.Body = io.NopCloser(bytes.NewReader(nil))
input.Options.ExcludeResponseBody = true
}
assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path)
return rec
}
func TestResponsesMatchOpenAPI(t *testing.T) {
gitea := newGitea(t, http.StatusOK)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea })
v := newSpecValidator(t, e)
song := e.addSong("Game A", "Song", "0123456789")
songURL := "/api/v1/songs/" + song.Uuid.String()
// Empty states and errors.
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound)
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity)
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
// Music.
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK)
v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1")
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK)
// Library.
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
v.check(http.MethodGet, songURL, "", http.StatusOK)
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
// Characters, downloads, server info.
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest)
v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)
v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound)
v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound)
v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK)
// Statistics (empty database).
for _, path := range []string{
"/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played",
"/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played",
} {
v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK)
}
v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Sync, including the locked state and the result after it finishes.
release := e.blockSync()
v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK)
v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked)
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked)
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
release()
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
// Tokens and authentication.
auth := newTestEnv(t, withAuth("secret"))
va := newSpecValidator(t, auth)
va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized)
rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK)
var token TokenResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token))
bearer := []string{"Authorization", "Bearer " + token.Token}
va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized)
va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...)
va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...)
// Statistics with data, through the canned statistics store.
stats := newTestEnv(t)
stats.server.stats = statsService()
stats.handler = stats.server.RegisterRoutes()
vs := newSpecValidator(t, stats)
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK)
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Every documented operation was checked at least once (statistics and tokens use other environments).
spec := loadSpec(t, e)
for path, item := range spec.Paths.Map() {
for method := range item.Operations() {
key := method + " " + path
assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key)
}
}
}