diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-18 14:14:36 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-18 14:14:36 +0300 |
| commit | b5f97eb3adae3a80cc4fd47ea34a5dc83cba01b0 (patch) | |
| tree | fa671ad305310d62f8515f4f165c0b2a841a4214 /player-server/internal/api | |
| parent | 6d54aec98f0a24d9cfa10173f716f05027af10e7 (diff) | |
Add /api/v1/ integration test suite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-server/internal/api')
| -rw-r--r-- | player-server/internal/api/integration_test.go | 1058 |
1 files changed, 1058 insertions, 0 deletions
diff --git a/player-server/internal/api/integration_test.go b/player-server/internal/api/integration_test.go new file mode 100644 index 0000000..c5e7d18 --- /dev/null +++ b/player-server/internal/api/integration_test.go @@ -0,0 +1,1058 @@ +package api + +// Integration tests for all /api/v1/ routes. +// +// Strategy: spin up a full Server backed by a real in-memory SQLite database so +// that auth, sessions, and API tokens all work end-to-end. Service layers that +// need file-system access (browse, stream, write, admin) are replaced with +// MockMediaService / MockAdminService so tests stay fast and hermetic. +// +// Each test helper (bootstrapAdmin, loginAsUser, mintToken, etc.) uses +// t.Helper() so failure messages point to the call site, not the helper. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "testing/fstest" + "time" + + "codeberg.org/snonux/player/internal" + "codeberg.org/snonux/player/internal/auth" + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" + "codeberg.org/snonux/player/internal/service" +) + +// ------------------------------------------------------------------ +// Test-server constructor +// ------------------------------------------------------------------ + +// integrationEnv holds the live server needed by test helpers. +type integrationEnv struct { + srv *Server +} + +// newIntegrationServer spins up a full Server with a real in-memory SQLite +// store, a real session manager, and a low-cost bcrypt hasher. Service +// interfaces that touch the file system (browse, write, admin, etc.) are +// replaced with permissive mocks so tests focus on HTTP routing and auth. +func newIntegrationServer(t *testing.T) *integrationEnv { + t.Helper() + + store, sm, hasher, authSvc := buildIntegrationAuth(t) + + cfg := &internal.Config{ + MediaPageSize: 50, + ShareDefaultExpiryDays: 7, + SessionTimeoutHours: 24, + SecureCookies: false, + } + + staticFS := fstest.MapFS{ + "index.html": {Data: []byte("index")}, + "login.html": {Data: []byte("login")}, + "bootstrap.html": {Data: []byte("bootstrap")}, + "share.html": {Data: []byte("share")}, + } + + srv := NewServer(ServerDeps{ + Store: store, + Hasher: hasher, + SessionManager: sm, + Config: cfg, + Services: ServerServices{ + Browse: buildBrowseMock(), + Write: buildBrowseMock(), + Share: buildBrowseMock(), + Tag: buildBrowseMock(), + Favorite: buildBrowseMock(), + Note: buildBrowseMock(), + Admin: buildAdminMock(), + Progress: buildProgressMock(), + Auth: authSvc, + Podcast: &integrationPodcastService{}, + }, + StaticFS: http.FS(staticFS), + }) + + return &integrationEnv{srv: srv} +} + +// buildIntegrationAuth creates the real SQLite store, session manager, and auth +// service used across integration tests. +func buildIntegrationAuth(t *testing.T) (*repository.SQLite, auth.SessionManager, auth.Hasher, service.AuthService) { + t.Helper() + store, err := repository.Open(":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + clk := &clock.MockClock{T: time.Now()} + sm := auth.NewSessionManager(store, clk, time.Hour) + hasher := auth.NewBCryptHasher(4) // cost 4 is minimum, fast enough for tests + authSvc := service.NewAuthService(store, clk, hasher, sm, auth.NewTokenManager()) + return store, sm, hasher, authSvc +} + +// buildBrowseMock returns a permissive MockMediaService for integration tests. +func buildBrowseMock() *service.MockMediaService { + return &service.MockMediaService{ + ListSetsFunc: func(_ context.Context, _ int64) ([]model.Set, error) { + return []model.Set{{ID: 1, Name: "test-set"}}, nil + }, + ListMediaFunc: func(_ context.Context, _ int64, _ service.MediaQueryFilter) ([]model.Media, error) { + return []model.Media{}, nil + }, + GetMediaDetailFunc: func(_ context.Context, mediaID, _ int64) (*service.MediaDetail, error) { + return &service.MediaDetail{Media: &model.Media{ID: mediaID}}, nil + }, + BrowseSetFunc: func(_ context.Context, setID, _ int64, _ string) (*service.BrowseResult, error) { + return &service.BrowseResult{CurrentPath: "/"}, nil + }, + } +} + +// buildAdminMock returns a permissive MockAdminService for integration tests. +func buildAdminMock() *service.MockAdminService { + return &service.MockAdminService{ + ListUsersFunc: func(_ context.Context) ([]model.User, error) { + return []model.User{{ID: 1, Username: "admin", IsAdmin: true}}, nil + }, + ListTrashFunc: func(_ context.Context) ([]model.Media, error) { return nil, nil }, + TriggerRescanFunc: func(_ context.Context) error { return nil }, + ScanProgressFunc: func(_ context.Context) model.ScanProgress { return model.ScanProgress{} }, + ListPermissionsFunc: func(_ context.Context) (*service.PermissionsMatrix, error) { return &service.PermissionsMatrix{}, nil }, + CreateUserFunc: func(_ context.Context, username, _ string, isAdmin bool) (*model.User, error) { + return &model.User{ID: 2, Username: username, IsAdmin: isAdmin}, nil + }, + DeleteUserFunc: func(_ context.Context, _, _ int64) error { return nil }, + GrantPermissionFunc: func(_ context.Context, _, _ int64, _ model.Role) error { return nil }, + RevokePermissionFunc: func(_ context.Context, _, _ int64) error { return nil }, + } +} + +// buildProgressMock returns a permissive MockProgressService for integration tests. +func buildProgressMock() *service.MockProgressService { + return &service.MockProgressService{ + UpdateProgressFunc: func(_ context.Context, _ string, _, _ int64, _ float64) error { return nil }, + BatchUpdateProgressFunc: func(_ context.Context, _ string, _ int64, _ []service.ProgressUpdate) error { + return nil + }, + MarkFinishedFunc: func(_ context.Context, _, _ int64) error { return nil }, + MarkNotStartedFunc: func(_ context.Context, _, _ int64) error { return nil }, + ListInProgressFunc: func(_ context.Context, _ int64) ([]model.Media, error) { return nil, nil }, + } +} + +// ------------------------------------------------------------------ +// User / session helpers +// ------------------------------------------------------------------ + +// bootstrapAdmin creates the first (admin) account via the real Bootstrap flow +// and returns the user ID + session cookie. +func bootstrapAdmin(t *testing.T, env *integrationEnv) (int64, *http.Cookie) { + t.Helper() + + body := jsonBody(t, map[string]string{"username": "admin", "password": "secret123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("bootstrap: want 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + ID int64 `json:"id"` + } + mustDecodeJSON(t, rr.Body.Bytes(), &resp) + cookie := sessionCookieFromResponse(t, rr) + return resp.ID, cookie +} + +// loginAsUser authenticates with the given credentials and returns the session cookie. +func loginAsUser(t *testing.T, env *integrationEnv, username, password string) *http.Cookie { + t.Helper() + + body := jsonBody(t, map[string]string{"username": username, "password": password}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("login: want 200, got %d: %s", rr.Code, rr.Body.String()) + } + return sessionCookieFromResponse(t, rr) +} + +// mintToken mints a Bearer token for an already-authenticated user (identified +// by a valid session cookie) and returns the raw plaintext token string. +func mintToken(t *testing.T, env *integrationEnv, cookie *http.Cookie) string { + t.Helper() + + body := jsonBody(t, map[string]string{"name": "integration-test-token"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("mint token: want 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Token string `json:"token"` + } + mustDecodeJSON(t, rr.Body.Bytes(), &resp) + if resp.Token == "" { + t.Fatal("mint token: empty token in response") + } + return resp.Token +} + +// sessionCookieFromResponse extracts the "session" cookie from a recorder's +// response headers, failing the test if it is missing. +func sessionCookieFromResponse(t *testing.T, rr *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rr.Result().Cookies() { + if c.Name == "session" { + return c + } + } + t.Fatal("expected session cookie in response") + return nil +} + +// ------------------------------------------------------------------ +// Request / response helpers +// ------------------------------------------------------------------ + +// jsonBody encodes v as JSON and returns an io.Reader; fails the test on error. +func jsonBody(t *testing.T, v any) *bytes.Reader { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal json: %v", err) + } + return bytes.NewReader(b) +} + +// mustDecodeJSON decodes data into v; fails the test on error. +func mustDecodeJSON(t *testing.T, data []byte, v any) { + t.Helper() + if err := json.Unmarshal(data, v); err != nil { + t.Fatalf("unmarshal json: %v: raw=%s", err, string(data)) + } +} + +// doRequest performs a single HTTP request against the server using the provided +// authCookie (for cookie auth) or bearerToken (for Bearer auth). Exactly one of +// the two should be non-zero per call. Returns the recorded response. +func doRequest(t *testing.T, env *integrationEnv, method, path string, body *bytes.Reader, + authCookie *http.Cookie, bearerToken string, +) *httptest.ResponseRecorder { + t.Helper() + + // Pass an untyped nil to httptest.NewRequest when body is nil; a typed nil + // *bytes.Reader would satisfy io.Reader as a non-nil interface and cause a panic. + var bodyIO io.Reader + if body != nil { + bodyIO = body + } + req := httptest.NewRequest(method, path, bodyIO) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if authCookie != nil { + req.AddCookie(authCookie) + } + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + return rr +} + +// assertStatus fails the test if rr.Code != want. +func assertStatus(t *testing.T, rr *httptest.ResponseRecorder, want int) { + t.Helper() + if rr.Code != want { + t.Errorf("want status %d, got %d: %s", want, rr.Code, rr.Body.String()) + } +} + +// ------------------------------------------------------------------ +// Cross-cutting flow test +// ------------------------------------------------------------------ + +// TestIntegration_FullFlow exercises the canonical user journey: +// bootstrap → login → mint token → browse → progress → favorite → logout. +// Each step is performed twice – once with cookie auth and once with Bearer auth +// – to verify that both auth paths work end-to-end. +func TestIntegration_FullFlow(t *testing.T) { + env := newIntegrationServer(t) + + // 1. Bootstrap creates the first admin user and returns a session cookie. + _, cookie := bootstrapAdmin(t, env) + + // 2. Mint a Bearer token while authenticated via cookie. + token := mintToken(t, env, cookie) + + // 3. Log out to confirm the logout endpoint works. + // The logout route is registered from /api/logout → /api/v1/logout. + rr := doRequest(t, env, http.MethodPost, "/api/v1/logout", nil, cookie, "") + assertStatus(t, rr, http.StatusNoContent) + + // 4. Log back in to get a fresh cookie for the remaining steps. + cookie = loginAsUser(t, env, "admin", "secret123") + + // 5. GET /api/v1/sets – list sets; verify with both auth methods. + for _, name := range []string{"cookie", "bearer"} { + t.Run("list-sets/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/sets", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/sets", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } + + // 6. GET /api/v1/media – list media. + for _, name := range []string{"cookie", "bearer"} { + t.Run("list-media/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } + + // 7. POST /api/v1/progress – record a playback position. + // Field name matches the server's struct: position_seconds. + progressBody := jsonBody(t, map[string]any{"media_id": 1, "position_seconds": 42.0}) + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress", progressBody, cookie, "") + assertStatus(t, rr, http.StatusOK) + + // 8. GET /api/v1/in-progress – list in-progress media. + rr = doRequest(t, env, http.MethodGet, "/api/v1/in-progress", nil, cookie, "") + assertStatus(t, rr, http.StatusOK) + + // 9. POST /api/v1/media/1/favorite – toggle favorite. + rr = doRequest(t, env, http.MethodPost, "/api/v1/media/1/favorite", nil, cookie, "") + assertStatus(t, rr, http.StatusOK) + + // 10. GET /api/v1/tags – list tags. + rr = doRequest(t, env, http.MethodGet, "/api/v1/tags", nil, cookie, "") + assertStatus(t, rr, http.StatusOK) +} + +// ------------------------------------------------------------------ +// Public endpoint tests +// ------------------------------------------------------------------ + +// TestIntegration_PublicEndpoints verifies that /healthz, /readyz, and the +// bootstrap/login HTML pages are accessible without any authentication. +func TestIntegration_PublicEndpoints(t *testing.T) { + env := newIntegrationServer(t) + // Bootstrap first so the BootstrapRedirect middleware doesn't redirect. + bootstrapAdmin(t, env) + + tests := []struct { + method string + path string + want int + }{ + {http.MethodGet, "/healthz", http.StatusOK}, + {http.MethodGet, "/readyz", http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, tt.want) + }) + } +} + +// ------------------------------------------------------------------ +// Auth endpoint tests +// ------------------------------------------------------------------ + +// TestIntegration_Auth tests bootstrap, login, token management, and logout. +func TestIntegration_Auth(t *testing.T) { + t.Run("bootstrap/ok", func(t *testing.T) { + env := newIntegrationServer(t) + body := jsonBody(t, map[string]string{"username": "admin", "password": "pass1234"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("bootstrap/already-bootstrapped", func(t *testing.T) { + env := newIntegrationServer(t) + bootstrapAdmin(t, env) + // Second bootstrap attempt must be rejected. + body := jsonBody(t, map[string]string{"username": "admin2", "password": "pass1234"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, http.StatusForbidden) + }) + + t.Run("login/ok", func(t *testing.T) { + env := newIntegrationServer(t) + bootstrapAdmin(t, env) + cookie := loginAsUser(t, env, "admin", "secret123") + if cookie == nil { + t.Fatal("expected session cookie") + } + }) + + t.Run("login/bad-credentials", func(t *testing.T) { + env := newIntegrationServer(t) + bootstrapAdmin(t, env) + body := jsonBody(t, map[string]string{"username": "admin", "password": "wrong"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, http.StatusUnauthorized) + }) + + t.Run("logout/ok", func(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + // Logout is at /api/v1/logout (mapped from /api/logout via handleBoth). + rr := doRequest(t, env, http.MethodPost, "/api/v1/logout", nil, cookie, "") + assertStatus(t, rr, http.StatusNoContent) + }) + + t.Run("logout/unauthenticated", func(t *testing.T) { + env := newIntegrationServer(t) + bootstrapAdmin(t, env) + req := httptest.NewRequest(http.MethodPost, "/api/v1/logout", nil) + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, http.StatusUnauthorized) + }) +} + +// ------------------------------------------------------------------ +// API token endpoint tests +// ------------------------------------------------------------------ + +// TestIntegration_APITokens tests minting, listing, and revoking API tokens, +// and also verifies that a minted token can authenticate subsequent requests. +func TestIntegration_APITokens(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + + t.Run("create/ok-cookie", func(t *testing.T) { + body := jsonBody(t, map[string]string{"name": "my-token"}) + rr := doRequest(t, env, http.MethodPost, "/api/v1/auth/tokens", body, cookie, "") + assertStatus(t, rr, http.StatusOK) + + var resp struct { + Token string `json:"token"` + ID int64 `json:"id"` + } + mustDecodeJSON(t, rr.Body.Bytes(), &resp) + if resp.Token == "" { + t.Error("expected non-empty token") + } + if resp.ID == 0 { + t.Error("expected non-zero token ID") + } + }) + + t.Run("create/unauthenticated", func(t *testing.T) { + body := jsonBody(t, map[string]string{"name": "my-token"}) + rr := doRequest(t, env, http.MethodPost, "/api/v1/auth/tokens", body, nil, "") + assertStatus(t, rr, http.StatusUnauthorized) + }) + + t.Run("list/ok-cookie", func(t *testing.T) { + rr := doRequest(t, env, http.MethodGet, "/api/v1/auth/tokens", nil, cookie, "") + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("bearer-auth/ok", func(t *testing.T) { + // Mint a token and immediately use it to authenticate a subsequent request. + token := mintToken(t, env, cookie) + rr := doRequest(t, env, http.MethodGet, "/api/v1/auth/tokens", nil, nil, token) + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("revoke/ok", func(t *testing.T) { + // Mint, list to get the ID, then revoke. + token := mintToken(t, env, cookie) + + rr := doRequest(t, env, http.MethodGet, "/api/v1/auth/tokens", nil, nil, token) + assertStatus(t, rr, http.StatusOK) + + var tokens []struct { + ID int64 `json:"id"` + } + mustDecodeJSON(t, rr.Body.Bytes(), &tokens) + if len(tokens) == 0 { + t.Fatal("expected at least one token in list") + } + + // Revoke the last listed token. + idStr := strconv.FormatInt(tokens[len(tokens)-1].ID, 10) + rr = doRequest(t, env, http.MethodDelete, "/api/v1/auth/tokens/"+idStr, nil, cookie, "") + assertStatus(t, rr, http.StatusNoContent) + }) +} + +// ------------------------------------------------------------------ +// Protected route 401 tests +// ------------------------------------------------------------------ + +// TestIntegration_Unauthenticated verifies that all protected /api/v1/ routes +// return 401 when accessed without credentials. Routes are tested with an empty +// Authorization header so the middleware cannot fall through to cookie auth. +func TestIntegration_Unauthenticated(t *testing.T) { + env := newIntegrationServer(t) + // Bootstrap to prevent BootstrapRedirect from firing. + bootstrapAdmin(t, env) + + routes := []struct { + method string + path string + }{ + // Auth + {http.MethodGet, "/api/v1/auth/tokens"}, + {http.MethodPost, "/api/v1/auth/tokens"}, + {http.MethodDelete, "/api/v1/auth/tokens/1"}, + {http.MethodPost, "/api/v1/logout"}, + // Config + {http.MethodGet, "/api/v1/config"}, + // Sets + {http.MethodGet, "/api/v1/sets"}, + {http.MethodGet, "/api/v1/sets/1/browse"}, + {http.MethodGet, "/api/v1/sets/1/cover"}, + {http.MethodPost, "/api/v1/sets/1/cover"}, + {http.MethodPost, "/api/v1/sets/1/upload"}, + // Media + {http.MethodGet, "/api/v1/media"}, + {http.MethodGet, "/api/v1/media/1"}, + {http.MethodGet, "/api/v1/media/1/stream"}, + {http.MethodGet, "/api/v1/media/1/download"}, + {http.MethodGet, "/api/v1/media/1/thumbnail"}, + {http.MethodPost, "/api/v1/media/1/thumbnail"}, + {http.MethodPost, "/api/v1/media/1/favorite"}, + {http.MethodPost, "/api/v1/media/1/tags"}, + {http.MethodDelete, "/api/v1/media/1/tags/mytag"}, + {http.MethodDelete, "/api/v1/media/1"}, + {http.MethodPost, "/api/v1/media/1/restore"}, + {http.MethodPost, "/api/v1/media/1/shares"}, + {http.MethodGet, "/api/v1/media/1/shares"}, + {http.MethodGet, "/api/v1/media/1/playback"}, + // Tags + {http.MethodGet, "/api/v1/tags"}, + // Notes + {http.MethodGet, "/api/v1/media/1/notes"}, + {http.MethodPost, "/api/v1/media/1/notes"}, + {http.MethodDelete, "/api/v1/media/1/notes"}, + // Progress + {http.MethodPost, "/api/v1/progress"}, + {http.MethodPost, "/api/v1/progress/batch"}, + {http.MethodPost, "/api/v1/progress/status"}, + {http.MethodGet, "/api/v1/in-progress"}, + // Shares + {http.MethodDelete, "/api/v1/shares/sometoken"}, + {http.MethodGet, "/api/v1/shares"}, + // Admin + {http.MethodGet, "/api/v1/admin/trash"}, + {http.MethodPost, "/api/v1/admin/rescan"}, + {http.MethodGet, "/api/v1/admin/scan-progress"}, + {http.MethodGet, "/api/v1/admin/users"}, + {http.MethodPost, "/api/v1/admin/users"}, + {http.MethodDelete, "/api/v1/admin/users/1"}, + {http.MethodGet, "/api/v1/admin/permissions"}, + {http.MethodPost, "/api/v1/admin/permissions"}, + {http.MethodDelete, "/api/v1/admin/permissions"}, + // Podcasts + {http.MethodGet, "/api/v1/podcasts"}, + {http.MethodPost, "/api/v1/podcasts"}, + {http.MethodGet, "/api/v1/podcasts/1/episodes"}, + {http.MethodPost, "/api/v1/podcasts/episodes/1/download"}, + {http.MethodPost, "/api/v1/podcasts/episodes/1/complete"}, + } + + for _, tt := range routes { + tt := tt + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, http.StatusUnauthorized) + }) + } +} + +// TestIntegration_InvalidBearer verifies that an invalid Bearer token causes 401. +func TestIntegration_InvalidBearer(t *testing.T) { + env := newIntegrationServer(t) + bootstrapAdmin(t, env) + + rr := doRequest(t, env, http.MethodGet, "/api/v1/sets", nil, nil, "invalid-token-value") + assertStatus(t, rr, http.StatusUnauthorized) +} + +// ------------------------------------------------------------------ +// Config endpoint +// ------------------------------------------------------------------ + +// TestIntegration_Config verifies that GET /api/v1/config returns 200 for +// authenticated users (both cookie and Bearer). +func TestIntegration_Config(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run(name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/config", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/config", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Sets endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Sets verifies GET /api/v1/sets and GET /api/v1/sets/{id}/browse +// for both auth methods, and 401 without credentials. +func TestIntegration_Sets(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run("list-sets/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/sets", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/sets", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("browse-set/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/sets/1/browse", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/sets/1/browse", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Media endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Media covers GET /api/v1/media, GET /api/v1/media/{id}, and +// POST /api/v1/media/{id}/favorite for both auth methods. +func TestIntegration_Media(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run("list-media/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("get-media/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("favorite/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodPost, "/api/v1/media/1/favorite", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodPost, "/api/v1/media/1/favorite", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Tags endpoint +// ------------------------------------------------------------------ + +// TestIntegration_Tags verifies GET /api/v1/tags returns 200 for both auth methods. +func TestIntegration_Tags(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run(name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/tags", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/tags", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Progress endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Progress covers POST /api/v1/progress, POST /api/v1/progress/batch, +// POST /api/v1/progress/status, and GET /api/v1/in-progress. +func TestIntegration_Progress(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + progressPayload := func() *bytes.Reader { + // Field name matches the server's struct: position_seconds. + return jsonBody(t, map[string]any{"media_id": 1, "position_seconds": 30.0}) + } + + statusPayload := func() *bytes.Reader { + return jsonBody(t, map[string]any{"media_id": 1, "status": "finished"}) + } + + batchPayload := func() *bytes.Reader { + return jsonBody(t, map[string]any{ + "updates": []map[string]any{ + {"media_id": 1, "position_seconds": 42.0, "observed_at": time.Now().Format(time.RFC3339)}, + }, + }) + } + + for _, name := range []string{"cookie", "bearer"} { + t.Run("update-progress/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress", progressPayload(), cookie, "") + } else { + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress", progressPayload(), nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("batch-progress/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress/batch", batchPayload(), cookie, "") + } else { + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress/batch", batchPayload(), nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("progress-status/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress/status", statusPayload(), cookie, "") + } else { + rr = doRequest(t, env, http.MethodPost, "/api/v1/progress/status", statusPayload(), nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("in-progress/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/in-progress", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/in-progress", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Notes endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Notes covers the full note CRUD lifecycle (GET / POST / DELETE) +// for both auth methods. +func TestIntegration_Notes(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + noteBody := func() *bytes.Reader { + return jsonBody(t, map[string]string{"content": "my note"}) + } + + for _, name := range []string{"cookie", "bearer"} { + t.Run("get-note/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1/notes", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1/notes", nil, nil, token) + } + // 200 (note exists) or 204 (no note) are both valid; 404 when media absent. + if rr.Code != http.StatusOK && rr.Code != http.StatusNoContent && rr.Code != http.StatusNotFound { + t.Errorf("want 200, 204, or 404, got %d: %s", rr.Code, rr.Body.String()) + } + }) + + t.Run("upsert-note/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodPost, "/api/v1/media/1/notes", noteBody(), cookie, "") + } else { + rr = doRequest(t, env, http.MethodPost, "/api/v1/media/1/notes", noteBody(), nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("delete-note/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodDelete, "/api/v1/media/1/notes", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodDelete, "/api/v1/media/1/notes", nil, nil, token) + } + // Handler returns 200 {"status":"ok"} on success. + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Shares endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Shares covers GET /api/v1/media/{id}/shares and +// GET /api/v1/shares for both auth methods. +func TestIntegration_Shares(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run("list-media-shares/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1/shares", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1/shares", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + + t.Run("list-my-shares/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/shares", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/shares", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Admin endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Admin verifies that admin-only routes return 200 for an admin +// user via both cookie and Bearer auth methods. +func TestIntegration_Admin(t *testing.T) { + env := newIntegrationServer(t) + _, adminCookie := bootstrapAdmin(t, env) + adminToken := mintToken(t, env, adminCookie) + + adminRoutes := []struct { + method string + path string + }{ + {http.MethodGet, "/api/v1/admin/trash"}, + {http.MethodGet, "/api/v1/admin/users"}, + {http.MethodGet, "/api/v1/admin/permissions"}, + {http.MethodGet, "/api/v1/admin/scan-progress"}, + } + + for _, tt := range adminRoutes { + tt := tt + for _, name := range []string{"cookie", "bearer"} { + t.Run(tt.method+" "+tt.path+"/"+name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, tt.method, tt.path, nil, adminCookie, "") + } else { + rr = doRequest(t, env, tt.method, tt.path, nil, nil, adminToken) + } + assertStatus(t, rr, http.StatusOK) + }) + } + } +} + +// TestIntegration_AdminUnauthenticated verifies that admin-only routes return +// 401 without credentials. +func TestIntegration_AdminUnauthenticated(t *testing.T) { + env := newIntegrationServer(t) + bootstrapAdmin(t, env) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + rr := httptest.NewRecorder() + env.srv.ServeHTTP(rr, req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +// ------------------------------------------------------------------ +// Podcast endpoints +// ------------------------------------------------------------------ + +// TestIntegration_Podcasts verifies that GET /api/v1/podcasts returns 200 for +// authenticated users (both auth methods). +func TestIntegration_Podcasts(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run(name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/podcasts", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/podcasts", nil, nil, token) + } + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// ------------------------------------------------------------------ +// Playback hints endpoint +// ------------------------------------------------------------------ + +// TestIntegration_Playback verifies that GET /api/v1/media/{id}/playback +// returns 200 for both auth methods. +func TestIntegration_Playback(t *testing.T) { + env := newIntegrationServer(t) + _, cookie := bootstrapAdmin(t, env) + token := mintToken(t, env, cookie) + + for _, name := range []string{"cookie", "bearer"} { + t.Run(name, func(t *testing.T) { + var rr *httptest.ResponseRecorder + if name == "cookie" { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1/playback", nil, cookie, "") + } else { + rr = doRequest(t, env, http.MethodGet, "/api/v1/media/1/playback", ni |
