summaryrefslogtreecommitdiff
path: root/player-server
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 00:18:29 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 00:18:29 +0300
commit893e2aca022e6e9d72019a80b866334a777f60cc (patch)
tree9814363334da3f674b0079b8ce66e52589b8724b /player-server
parent89ef249a43ce1edcd8a3e217542de8f31c1a4c15 (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.go18
-rw-r--r--player-server/internal/api/handlers_test.go62
-rw-r--r--player-server/internal/api/server.go3
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))
}