Returns every character image with when it was last changed, in Unix seconds, so clients can cache the images and download only the ones that changed.
273 lines
11 KiB
Go
273 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/details",
|
|
"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/details", "", 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)
|
|
}
|
|
}
|
|
}
|