From 893e2aca022e6e9d72019a80b866334a777f60cc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 21 May 2026 00:18:29 +0300 Subject: 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 --- player-server/internal/api/handlers_auth.go | 18 +++++++++ player-server/internal/api/handlers_test.go | 62 +++++++++++++++++++++++++++++ player-server/internal/api/server.go | 3 ++ 3 files changed, 83 insertions(+) (limited to 'player-server') 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)) } -- cgit v1.2.3