diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 00:18:29 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 00:18:29 +0300 |
| commit | 893e2aca022e6e9d72019a80b866334a777f60cc (patch) | |
| tree | 9814363334da3f674b0079b8ce66e52589b8724b /player-server | |
| parent | 89ef249a43ce1edcd8a3e217542de8f31c1a4c15 (diff) | |
Add GET /api/v1/auth/count endpoint and first-run routing in Android app
Server: expose a public countUsers endpoint (GET /api/v1/auth/count)
so mobile clients can detect first-run (count=0) without a session.
Android: wire countUsers via DioPlayerApiClient, add firstRunProvider
(FutureProvider), update go_router redirect to drive /bootstrap vs /login
based on the count, rework LoginScreen to handle loading/error states, and
add widget tests for the new login screen and smoke-test updates.
Fix review issues: correct FutureProvider cache-lifetime comment in
first_run_provider.dart; add TestServer_CountUsers covering zero-users
and users-exist cases to handlers_test.go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-server')
| -rw-r--r-- | player-server/internal/api/handlers_auth.go | 18 | ||||
| -rw-r--r-- | player-server/internal/api/handlers_test.go | 62 | ||||
| -rw-r--r-- | player-server/internal/api/server.go | 3 |
3 files changed, 83 insertions, 0 deletions
diff --git a/player-server/internal/api/handlers_auth.go b/player-server/internal/api/handlers_auth.go index c770380..8607ec7 100644 --- a/player-server/internal/api/handlers_auth.go +++ b/player-server/internal/api/handlers_auth.go @@ -100,6 +100,24 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"id": res.User.ID, "username": res.User.Username, "is_admin": res.User.IsAdmin}) } +// handleCountUsers returns the total number of registered users as a public +// JSON endpoint. Mobile clients use this to decide whether to redirect to +// the bootstrap screen (count == 0) or the login screen (count > 0) on +// first launch, without requiring a session or credentials. +// +// GET /api/v1/auth/count → 200 {"count": N} +func (s *Server) handleCountUsers(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.authSvc) { + return + } + count, err := s.authSvc.CountUsers(r.Context()) + if err != nil { + handleError(w, fmt.Errorf("count users: %w", err)) + return + } + writeJSON(w, http.StatusOK, map[string]int{"count": count}) +} + func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err == nil && cookie.Value != "" { diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go index 96c4552..4ba9403 100644 --- a/player-server/internal/api/handlers_test.go +++ b/player-server/internal/api/handlers_test.go @@ -670,6 +670,68 @@ func TestServer_Login(t *testing.T) { }) } +func TestServer_CountUsers(t *testing.T) { + cfg := &internal.Config{SessionTimeoutHours: 24} + + // zero-users case: server has no accounts yet (first-run / bootstrap state). + t.Run("zero users", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }, + }, + } + authSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 0, nil }, + GetUserByIDFunc: func(context.Context, int64) (*model.User, error) { return &model.User{ID: 1, IsAdmin: true}, nil }, + } + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/count", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + var resp map[string]int + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["count"] != 0 { + t.Fatalf("expected count=0, got %d", resp["count"]) + } + }) + + // one-or-more-users case: at least one account exists (normal operation). + t.Run("users exist", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 3, nil }, + }, + } + authSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 3, nil }, + GetUserByIDFunc: func(context.Context, int64) (*model.User, error) { return &model.User{ID: 1, IsAdmin: true}, nil }, + } + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/count", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + var resp map[string]int + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["count"] != 3 { + t.Fatalf("expected count=3, got %d", resp["count"]) + } + }) +} + func TestServer_SessionCookieSecure(t *testing.T) { hasher := &staticHasher{fixed: "hashed"} store := &repository.MockStore{ diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go index fa2ca74..e87c532 100644 --- a/player-server/internal/api/server.go +++ b/player-server/internal/api/server.go @@ -225,6 +225,9 @@ func (s *Server) routesPublic() { s.handlePublicFunc("/api/v1/auth/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap)) s.handlePublicFunc("/api/login", publicMethod(http.MethodPost, s.handleLogin)) s.handlePublicFunc("/api/v1/auth/login", publicMethod(http.MethodPost, s.handleLogin)) + // Public user-count endpoint: mobile clients query this on first launch to + // decide whether to show the bootstrap screen (count=0) or the login screen. + s.handlePublicFunc("/api/v1/auth/count", publicMethod(http.MethodGet, s.handleCountUsers)) s.handlePublicFunc("/healthz", publicMethod(http.MethodGet, s.handleHealthz)) s.handlePublicFunc("/readyz", publicMethod(http.MethodGet, s.handleReadyz)) } |
