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
This commit is contained in:
1 parent
090e794460
commit
7b144ae419
42 files changed
+2334
-40
No files matched your search
@@ -0,0 +1,283 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logging.Init("fatal", false)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type fakeDatabase struct {
|
||||
closed atomic.Bool
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func (f *fakeDatabase) Health(context.Context) map[string]string {
|
||||
return map[string]string{"status": "up"}
|
||||
}
|
||||
|
||||
func (f *fakeDatabase) Close() {
|
||||
if f.onClose != nil {
|
||||
f.onClose()
|
||||
}
|
||||
f.closed.Store(true)
|
||||
}
|
||||
|
||||
// testEnv is a Server backed by an in-memory database and temporary folders.
|
||||
type testEnv struct {
|
||||
t *testing.T
|
||||
cfg config.Config
|
||||
store *fakedb.Store
|
||||
db *fakeDatabase
|
||||
server *Server
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T, configure ...func(*config.Config)) *testEnv {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Port: 0,
|
||||
MusicPath: filepath.Join(base, "music"),
|
||||
CharactersPath: filepath.Join(base, "characters"),
|
||||
SoundCheckPath: filepath.Join(base, "songs"),
|
||||
FrontendPath: filepath.Join(base, "frontend"),
|
||||
DisableSessionAuth: true,
|
||||
ReleasesURL: "http://127.0.0.1:1",
|
||||
}
|
||||
for _, c := range configure {
|
||||
c(&cfg)
|
||||
}
|
||||
writeFile(t, filepath.Join(cfg.CharactersPath, "Adol.png"), "png")
|
||||
writeFile(t, filepath.Join(cfg.CharactersPath, "Aloy.jpg"), "jpg")
|
||||
writeFile(t, filepath.Join(base, "secret.png"), "secret")
|
||||
writeFile(t, filepath.Join(cfg.SoundCheckPath, "check.mp3"), "sound check")
|
||||
writeFile(t, filepath.Join(cfg.FrontendPath, "index.html"), "<html>frontend</html>")
|
||||
|
||||
store := fakedb.New()
|
||||
database := &fakeDatabase{}
|
||||
s, err := New(cfg, store, database)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { s.sync.Wait(); s.characters.Close() })
|
||||
return &testEnv{t: t, cfg: cfg, store: store, db: database, server: s, handler: s.httpServer.Handler}
|
||||
}
|
||||
|
||||
// addSong creates a song file and its database rows.
|
||||
func (e *testEnv) addSong(soundtrack, song, content string) repository.Song {
|
||||
e.t.Helper()
|
||||
dir := e.cfg.MusicPath + "/" + soundtrack + "/"
|
||||
fileName := song + ".mp3"
|
||||
writeFile(e.t, dir+fileName, content)
|
||||
st, ok := e.store.Soundtrack(soundtrack)
|
||||
if !ok {
|
||||
st = e.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: soundtrack, Path: dir})
|
||||
}
|
||||
return e.store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: song, Path: dir + fileName, FileName: &fileName})
|
||||
}
|
||||
|
||||
func (e *testEnv) do(method, path string, headers ...string) *httptest.ResponseRecorder {
|
||||
return e.doBody(method, path, "", headers...)
|
||||
}
|
||||
|
||||
func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest.ResponseRecorder {
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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/new", "/sync/quick", "/sync/full", "/sync/new/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 TestOpenAPIAndSwagger(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
for _, path := range []string{"/openapi", "/swagger/doc.json"} {
|
||||
rec := e.do(http.MethodGet, path)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
var spec struct {
|
||||
Paths map[string]map[string]any `json:"paths"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path)
|
||||
assert.Contains(t, spec.Paths, "/api/v1/token", path)
|
||||
assert.Contains(t, spec.Paths["/sync/reset"], "post", path)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user