Files
MusicServer/internal/server/routes_test.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

204 lines
7.0 KiB
Go

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/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 TestSyncAliasesAreRemoved(t *testing.T) {
e := newTestEnv(t)
for _, prefix := range []string{"", "/api/v1"} {
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} {
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
}
}
assert.False(t, e.server.sync.IsSyncing())
}
func TestV1HealthEndpoint(t *testing.T) {
e := newTestEnv(t)
rec := e.do(http.MethodGet, "/api/v1/health")
assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
}
func TestDocsAndSpecLinks(t *testing.T) {
e := newTestEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
for _, path := range []string{"/openapi.json", "/openapi.yaml", "/openapi-3.0.json"} {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
}
rec := e.do(http.MethodGet, "/openapi")
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
assert.Equal(t, "/openapi.json", rec.Header().Get("Location"))
assert.Equal(t, "/docs", e.do(http.MethodGet, "/swagger/index.html").Header().Get("Location"))
}
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)
}