package handler import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/johnzastrow/actalog/internal/domain" "github.com/johnzastrow/actalog/internal/service" ) func TestAdminUserHandler_UnlockUser_Unauthorized(t *testing.T) { handler := &AdminUserHandler{} req := createTestRequest(http.MethodPost, "/api/admin/users/1/unlock", "") rr := httptest.NewRecorder() handler.UnlockUser(rr, req) assertStatusCode(t, rr, http.StatusUnauthorized) assertBodyContains(t, rr, "Unauthorized") } func TestAdminUserHandler_UnlockUser_InvalidID(t *testing.T) { handler := &AdminUserHandler{} // chi.URLParam returns empty string without router context req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/abc/unlock", "", 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.UnlockUser(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestAdminUserHandler_DisableUser_Unauthorized(t *testing.T) { handler := &AdminUserHandler{} req := createTestRequest(http.MethodPost, "/api/admin/users/1/disable", "") rr := httptest.NewRecorder() handler.DisableUser(rr, req) assertStatusCode(t, rr, http.StatusUnauthorized) assertBodyContains(t, rr, "Unauthorized") } func TestAdminUserHandler_DisableUser_InvalidID(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/abc/disable", "", 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.DisableUser(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestAdminUserHandler_EnableUser_Unauthorized(t *testing.T) { handler := &AdminUserHandler{} req := createTestRequest(http.MethodPost, "/api/admin/users/1/enable", "") rr := httptest.NewRecorder() handler.EnableUser(rr, req) assertStatusCode(t, rr, http.StatusUnauthorized) assertBodyContains(t, rr, "Unauthorized") } func TestAdminUserHandler_EnableUser_InvalidID(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/abc/enable", "", 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.EnableUser(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestAdminUserHandler_ChangeUserRole_Unauthorized(t *testing.T) { handler := &AdminUserHandler{} req := createTestRequest(http.MethodPut, "/api/admin/users/1/role", `{"role": "admin"}`) rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) assertStatusCode(t, rr, http.StatusUnauthorized) assertBodyContains(t, rr, "Unauthorized") } func TestAdminUserHandler_ChangeUserRole_InvalidID(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/abc/role", `{"role": "admin"}`, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestAdminUserHandler_ChangeUserRole_InvalidJSON(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/1/role", "{bad json", 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) // Without chi router context, URL param is empty -> Invalid user ID assertStatusCode(t, rr, http.StatusBadRequest) } func TestAdminUserHandler_ChangeUserRole_InvalidRole(t *testing.T) { handler := &AdminUserHandler{} // Note: This test would need chi router context to get past the ID parsing // Testing the role validation directly would require a mock chi context req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/1/role", `{"role": "superuser"}`, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) // Without chi router context, URL param is empty -> Invalid user ID assertStatusCode(t, rr, http.StatusBadRequest) } func TestAdminUserHandler_ToggleEmailVerification_Unauthorized(t *testing.T) { handler := &AdminUserHandler{} req := createTestRequest(http.MethodPost, "/api/admin/users/1/toggle-email-verification", `{"verified": true}`) rr := httptest.NewRecorder() handler.ToggleEmailVerification(rr, req) assertStatusCode(t, rr, http.StatusUnauthorized) assertBodyContains(t, rr, "Unauthorized") } func TestAdminUserHandler_ToggleEmailVerification_InvalidID(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/abc/toggle-email-verification", `{"verified": true}`, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.ToggleEmailVerification(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestAdminUserHandler_GetUserDetails_InvalidID(t *testing.T) { handler := &AdminUserHandler{} // chi.URLParam returns empty string without router context req := createTestRequest(http.MethodGet, "/api/admin/users/abc", "") rr := httptest.NewRecorder() handler.GetUserDetails(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestAdminUserHandler_DeleteUser_Unauthorized(t *testing.T) { handler := &AdminUserHandler{} req := createTestRequest(http.MethodDelete, "/api/admin/users/1", "") rr := httptest.NewRecorder() handler.DeleteUser(rr, req) assertStatusCode(t, rr, http.StatusUnauthorized) assertBodyContains(t, rr, "Unauthorized") } func TestAdminUserHandler_DeleteUser_InvalidID(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodDelete, "/api/admin/users/abc", "", 1, "admin@example.com", "admin") rr := httptest.NewRecorder() handler.DeleteUser(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid user ID") } func TestNewAdminUserHandler(t *testing.T) { handler := NewAdminUserHandler(nil, nil, nil) if handler == nil { t.Error("NewAdminUserHandler should return a non-nil handler") } } func TestAdminUserHandler_ChangeUserRole_ValidIDInvalidJSON(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/1/role", "{bad json", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid request body") } func TestAdminUserHandler_ChangeUserRole_InvalidRoleValue(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/1/role", `{"role": "superuser"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Role must be 'athlete', 'coach', or 'admin'") } func TestAdminUserHandler_ToggleEmailVerification_ValidIDInvalidJSON(t *testing.T) { handler := &AdminUserHandler{} req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/1/toggle-email-verification", "{bad json", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.ToggleEmailVerification(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "Invalid request body") } // Removed 9 panic-expectation tests: // - TestAdminUserHandler_ListUsers_NilService // - TestAdminUserHandler_ChangeUserRole_ValidRole (2 subtests) // - TestAdminUserHandler_ToggleEmailVerification_ValidInput // - TestAdminUserHandler_UnlockUser_ValidID // - TestAdminUserHandler_DisableUser_ValidID // - TestAdminUserHandler_EnableUser_ValidID // - TestAdminUserHandler_GetUserDetails_ValidID // - TestAdminUserHandler_DeleteUser_ValidID // - TestAdminUserHandler_ListUsers_WithQueryParams (7 subtests) // These tests verified nil pointer panics, not business logic. // Tests with mock service func TestAdminUserHandler_ListUsers_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createTestRequest(http.MethodGet, "/api/admin/users", "") rr := httptest.NewRecorder() handler.ListUsers(rr, req) assertStatusCode(t, rr, http.StatusOK) assertBodyContains(t, rr, "users") assertBodyContains(t, rr, "total") } func TestAdminUserHandler_ListUsers_WithPagination(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) tests := []struct { name string query string wantCode int }{ {"with valid limit", "?limit=10", http.StatusOK}, {"with valid offset", "?offset=5", http.StatusOK}, {"with both params", "?limit=10&offset=5", http.StatusOK}, {"with invalid limit", "?limit=abc", http.StatusOK}, // falls back to default {"with negative limit", "?limit=-1", http.StatusOK}, // falls back to default {"with invalid offset", "?offset=abc", http.StatusOK}, // falls back to default {"with negative offset", "?offset=-1", http.StatusOK}, // falls back to default {"with zero limit", "?limit=0", http.StatusOK}, // falls back to default } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := createTestRequest(http.MethodGet, "/api/admin/users"+tt.query, "") rr := httptest.NewRecorder() handler.ListUsers(rr, req) assertStatusCode(t, rr, tt.wantCode) }) } } func TestAdminUserHandler_UnlockUser_UserNotFound(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/999/unlock", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "999") rr := httptest.NewRecorder() handler.UnlockUser(rr, req) // Service returns 500 for user not found assertStatusCode(t, rr, http.StatusInternalServerError) } func TestAdminUserHandler_UnlockUser_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/1/unlock", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.UnlockUser(rr, req) // User exists, should succeed assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_DisableUser_InvalidJSON(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) // Handler treats invalid JSON as no reason (reason is optional) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/2/disable", "{bad json", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.DisableUser(rr, req) // Handler succeeds with empty reason assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_DisableUser_EmptyReason(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) // Reason is optional, so empty body succeeds req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/2/disable", `{}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.DisableUser(rr, req) assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_DisableUser_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/2/disable", `{"reason": "Policy violation"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.DisableUser(rr, req) assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_DisableUser_CannotDisableSelf(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/1/disable", `{"reason": "Self disable"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.DisableUser(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "cannot disable your own account") } func TestAdminUserHandler_EnableUser_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/1/enable", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.EnableUser(rr, req) assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_ChangeUserRole_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/2/role", `{"role": "admin"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_ChangeUserRole_CannotChangeOwnRole(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPut, "/api/admin/users/1/role", `{"role": "athlete"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.ChangeUserRole(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "cannot change your own role") } func TestAdminUserHandler_ToggleEmailVerification_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/1/toggle-email-verification", `{"verified": true}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.ToggleEmailVerification(rr, req) assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_GetUserDetails_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createTestRequest(http.MethodGet, "/api/admin/users/1", "") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.GetUserDetails(rr, req) assertStatusCode(t, rr, http.StatusOK) } func TestAdminUserHandler_GetUserDetails_NotFound(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) req := createTestRequest(http.MethodGet, "/api/admin/users/999", "") req = addChiURLParam(req, "id", "999") rr := httptest.NewRecorder() handler.GetUserDetails(rr, req) assertStatusCode(t, rr, http.StatusNotFound) } func TestAdminUserHandler_DeleteUser_CannotDeleteSelf(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) // User 1 trying to delete themselves req := createAuthenticatedRequest(http.MethodDelete, "/api/admin/users/1", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "1") rr := httptest.NewRecorder() handler.DeleteUser(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "cannot delete your own account") } func TestAdminUserHandler_DeleteUser_Success(t *testing.T) { userService := createTestUserService() handler := NewAdminUserHandler(userService, nil, createTestLogger()) // Admin (user 1) deleting user 2 req := createAuthenticatedRequest(http.MethodDelete, "/api/admin/users/2", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.DeleteUser(rr, req) assertStatusCode(t, rr, http.StatusOK) } // knownUpdatedAt is a fixed non-zero timestamp used by the AdminUserService test helpers. var knownUpdatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // mockUserRepoNilOnMiss wraps MockUserRepository and returns (nil, nil) from // GetByID when the user is not found — matching the real repository contract. // The standard MockUserRepository incorrectly returns (nil, ErrMockNotFound). type mockUserRepoNilOnMiss struct { *MockUserRepository } func (m *mockUserRepoNilOnMiss) GetByID(id int64) (*domain.User, error) { if m.shouldError { return nil, m.errorToReturn } for _, u := range m.users { if u.ID == id { return u, nil } } return nil, nil // real-repo contract: missing row → (nil, nil) } // createTestAdminUserService creates an AdminUserService backed by mocks. // User 2 ("test@example.com") has UpdatedAt = knownUpdatedAt so that // optimistic-concurrency tests can supply a matching timestamp. func createTestAdminUserService() *service.AdminUserService { mockUserRepo := &mockUserRepoNilOnMiss{NewMockUserRepository()} // Set a known non-zero UpdatedAt on user 2 so the optimistic-concurrency // check in UpdateProfile can be exercised. for _, u := range mockUserRepo.users { if u.ID == 2 { u.UpdatedAt = knownUpdatedAt } } mockRefreshTokenRepo := NewMockRefreshTokenRepository() mockAuditLogRepo := NewMockAuditLogRepository() mockEmailService := NewMockEmailService() auditLogService := service.NewAuditLogService(mockAuditLogRepo) return service.NewAdminUserService( mockUserRepo, mockRefreshTokenRepo, mockEmailService, auditLogService, createTestLogger(), "http://localhost:3000", ) } // createTestAdminUserServiceWithProtectedUser returns an AdminUserService whose // user store includes a protected user (br8kwall@gmail.com) with ID 99, so that // handler tests can exercise the ErrProtectedUser → 403 path. func createTestAdminUserServiceWithProtectedUser() *service.AdminUserService { inner := NewMockUserRepository() inner.users = append(inner.users, &domain.User{ ID: 99, Email: "br8kwall@gmail.com", Name: "Protected User", Role: "admin", }) mockUserRepo := &mockUserRepoNilOnMiss{inner} mockRefreshTokenRepo := NewMockRefreshTokenRepository() mockAuditLogRepo := NewMockAuditLogRepository() mockEmailService := NewMockEmailService() auditLogService := service.NewAuditLogService(mockAuditLogRepo) return service.NewAdminUserService( mockUserRepo, mockRefreshTokenRepo, mockEmailService, auditLogService, createTestLogger(), "http://localhost:3000", ) } // TestAdminUserHandler_UpdateProfile_HappyPath — PATCH with correct updated_at → 200. func TestAdminUserHandler_UpdateProfile_HappyPath(t *testing.T) { adminUserService := createTestAdminUserService() handler := NewAdminUserHandler(nil, adminUserService, createTestLogger()) body := fmt.Sprintf(`{"name":"New Name","updated_at":"%s"}`, knownUpdatedAt.Format(time.RFC3339Nano)) req := createAuthenticatedRequest(http.MethodPatch, "/api/admin/users/2", body, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.UpdateProfile(rr, req) assertStatusCode(t, rr, http.StatusOK) assertBodyContains(t, rr, "New Name") } // TestAdminUserHandler_UpdateProfile_StaleUpdatedAtReturns409 — PATCH with old updated_at → 409. func TestAdminUserHandler_UpdateProfile_StaleUpdatedAtReturns409(t *testing.T) { adminUserService := createTestAdminUserService() handler := NewAdminUserHandler(nil, adminUserService, createTestLogger()) // Provide a timestamp that does NOT match the stored UpdatedAt. stale := knownUpdatedAt.Add(-time.Hour) body := fmt.Sprintf(`{"name":"Stale Name","updated_at":"%s"}`, stale.Format(time.RFC3339Nano)) req := createAuthenticatedRequest(http.MethodPatch, "/api/admin/users/2", body, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.UpdateProfile(rr, req) assertStatusCode(t, rr, http.StatusConflict) } // TestAdminUserHandler_UpdateProfile_MissingUpdatedAtReturns400 — PATCH without updated_at → 400. func TestAdminUserHandler_UpdateProfile_MissingUpdatedAtReturns400(t *testing.T) { adminUserService := createTestAdminUserService() handler := NewAdminUserHandler(nil, adminUserService, createTestLogger()) body := `{"name":"No Timestamp"}` req := createAuthenticatedRequest(http.MethodPatch, "/api/admin/users/2", body, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.UpdateProfile(rr, req) assertStatusCode(t, rr, http.StatusBadRequest) assertBodyContains(t, rr, "updated_at must be a valid non-zero timestamp") } // TestAdminUserHandler_ForcePasswordReset_Returns204 — POST force-password-reset → 204. func TestAdminUserHandler_ForcePasswordReset_Returns204(t *testing.T) { adminUserService := createTestAdminUserService() handler := NewAdminUserHandler(nil, adminUserService, createTestLogger()) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/2/force-password-reset", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "2") rr := httptest.NewRecorder() handler.ForcePasswordReset(rr, req) assertStatusCode(t, rr, http.StatusNoContent) } // TestAdminUserHandler_UpdateProfile_UnknownUserReturns404 verifies that // UpdateProfile returns HTTP 404 when the service returns ErrUserNotFound // (user ID does not exist in the repository). func TestAdminUserHandler_UpdateProfile_UnknownUserReturns404(t *testing.T) { adminUserService := createTestAdminUserService() handler := NewAdminUserHandler(nil, adminUserService, createTestLogger()) // User 999 does not exist — mock GetByID returns (nil, nil), triggering the // ErrUserNotFound sentinel path in AdminUserService.ensureNotProtected. body := fmt.Sprintf(`{"name":"Ghost","updated_at":"%s"}`, knownUpdatedAt.Format(time.RFC3339Nano)) req := createAuthenticatedRequest(http.MethodPatch, "/api/admin/users/999", body, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "999") rr := httptest.NewRecorder() handler.UpdateProfile(rr, req) assertStatusCode(t, rr, http.StatusNotFound) } // TestAdminUserHandler_ForcePasswordReset_OnProtectedUserReturns403 verifies // that ForcePasswordReset returns HTTP 403 with error code "protected_user" when // the target user is in the protected-user registry. func TestAdminUserHandler_ForcePasswordReset_OnProtectedUserReturns403(t *testing.T) { adminUserService := createTestAdminUserServiceWithProtectedUser() handler := NewAdminUserHandler(nil, adminUserService, createTestLogger()) // User 99 has email br8kwall@gmail.com — a protected account. req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/99/force-password-reset", "", 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "99") rr := httptest.NewRecorder() handler.ForcePasswordReset(rr, req) assertStatusCode(t, rr, http.StatusForbidden) var resp ErrorResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response body: %v", err) } if resp.Error != "protected_user" { t.Errorf("Error = %q, want %q", resp.Error, "protected_user") } } // stubAdminUserService is a hand-rolled test double that satisfies the handler's // adminUserServiceIface. It records the most recent call's arguments and returns // caller-controlled results, so each test can wire in the exact behaviour it // needs without standing up the full service + repo + audit-log graph. type stubAdminUserService struct { // CreateUser tracking createUserCalled bool createUserActorID int64 createUserFields service.CreateUserFields createUserResult *domain.User createUserErr error // UpdateProfile tracking (unused in current tests but required by the // adminUserServiceIface contract). updateProfileCalled bool updateProfileResult *domain.User updateProfileErr error // ForcePasswordReset tracking (unused in current tests but required by // the adminUserServiceIface contract). forcePasswordResetCalled bool forcePasswordResetErr error // SetPassword tracking setPasswordCalled bool setPasswordActorID int64 setPasswordTargetID int64 setPasswordPassword string setPasswordErr error } func (s *stubAdminUserService) CreateUser(actorID int64, fields service.CreateUserFields) (*domain.User, error) { s.createUserCalled = true s.createUserActorID = actorID s.createUserFields = fields return s.createUserResult, s.createUserErr } func (s *stubAdminUserService) UpdateProfile(actorID, targetID int64, fields service.ProfileUpdateFields, ifMatchUpdatedAt time.Time) (*domain.User, error) { s.updateProfileCalled = true return s.updateProfileResult, s.updateProfileErr } func (s *stubAdminUserService) ForcePasswordReset(actorID, targetID int64) error { s.forcePasswordResetCalled = true return s.forcePasswordResetErr } func (s *stubAdminUserService) SetPassword(actorID, targetID int64, newPassword string) error { s.setPasswordCalled = true s.setPasswordActorID = actorID s.setPasswordTargetID = targetID s.setPasswordPassword = newPassword return s.setPasswordErr } // TestAdminUserHandler_CreateUser_201Success exercises the happy path: valid // JSON in, 201 + user JSON out, service.CreateUser called with the decoded fields. func TestAdminUserHandler_CreateUser_201Success(t *testing.T) { stub := &stubAdminUserService{ createUserResult: &domain.User{ ID: 42, Email: "newathlete@example.com", Name: "New", Role: "athlete", }, } h := NewAdminUserHandler(nil, stub, nil) body := `{"email":"newathlete@example.com","password":"ValidPass123Long","name":"New","role":"athlete","email_verified":true}` req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users", body, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() h.CreateUser(rr, req) if rr.Code != http.StatusCreated { t.Errorf("Code = %d, want 201; body=%s", rr.Code, rr.Body.String()) } if !stub.createUserCalled { t.Error("service.CreateUser not invoked") } if stub.createUserActorID != 1 { t.Errorf("actorID = %d, want 1", stub.createUserActorID) } if stub.createUserFields.Email != "newathlete@example.com" { t.Errorf("Email = %q", stub.createUserFields.Email) } if !stub.createUserFields.EmailVerified { t.Error("EmailVerified should be true (decoded from request)") } } // TestAdminUserHandler_CreateUser_400OnInvalidJSON returns a clear bad-request // status without invoking the service. func TestAdminUserHandler_CreateUser_400OnInvalidJSON(t *testing.T) { stub := &stubAdminUserService{} h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users", `{not json`, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() h.CreateUser(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("Code = %d, want 400", rr.Code) } if stub.createUserCalled { t.Error("service should not be called on JSON decode failure") } } // TestAdminUserHandler_CreateUser_400OnValidationError surfaces InvalidInputError as 400. func TestAdminUserHandler_CreateUser_400OnValidationError(t *testing.T) { stub := &stubAdminUserService{ createUserErr: &domain.InvalidInputError{Field: "password", Message: "must be at least 12 characters"}, } h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users", `{"email":"a@b.com","password":"x","name":"X","role":"athlete"}`, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() h.CreateUser(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("Code = %d, want 400", rr.Code) } if !strings.Contains(rr.Body.String(), "password") { t.Errorf("body should include the field name; got %s", rr.Body.String()) } } // TestAdminUserHandler_CreateUser_409OnDuplicateEmail maps the service sentinel // to a 409 with the structured error code "duplicate_email". func TestAdminUserHandler_CreateUser_409OnDuplicateEmail(t *testing.T) { stub := &stubAdminUserService{createUserErr: service.ErrEmailAlreadyExists} h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users", `{"email":"a@b.com","password":"ValidPass123Long","name":"X","role":"athlete"}`, 1, "admin@example.com", "admin") rr := httptest.NewRecorder() h.CreateUser(rr, req) if rr.Code != http.StatusConflict { t.Errorf("Code = %d, want 409; body=%s", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "duplicate_email") { t.Errorf("body should include error code 'duplicate_email'; got %s", rr.Body.String()) } } // TestAdminUserHandler_SetPassword_204Success returns no body on success. func TestAdminUserHandler_SetPassword_204Success(t *testing.T) { stub := &stubAdminUserService{} h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/42/password", `{"new_password":"ValidPass123Long"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "42") rr := httptest.NewRecorder() h.SetPassword(rr, req) if rr.Code != http.StatusNoContent { t.Errorf("Code = %d, want 204; body=%s", rr.Code, rr.Body.String()) } if rr.Body.Len() != 0 { t.Errorf("body should be empty for 204; got %s", rr.Body.String()) } if !stub.setPasswordCalled { t.Error("service.SetPassword not invoked") } if stub.setPasswordTargetID != 42 { t.Errorf("targetID = %d, want 42", stub.setPasswordTargetID) } if stub.setPasswordActorID != 1 { t.Errorf("actorID = %d, want 1", stub.setPasswordActorID) } if stub.setPasswordPassword != "ValidPass123Long" { t.Errorf("password = %q, want %q", stub.setPasswordPassword, "ValidPass123Long") } } // TestAdminUserHandler_SetPassword_400OnInvalidJSON rejects malformed JSON // before invoking the service. func TestAdminUserHandler_SetPassword_400OnInvalidJSON(t *testing.T) { stub := &stubAdminUserService{} h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/42/password", `{not json`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "42") rr := httptest.NewRecorder() h.SetPassword(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("Code = %d, want 400", rr.Code) } if stub.setPasswordCalled { t.Error("service should not be called on JSON decode failure") } } // TestAdminUserHandler_SetPassword_404OnInvalidID returns 404 when the URL // {id} is not a valid integer. func TestAdminUserHandler_SetPassword_404OnInvalidID(t *testing.T) { stub := &stubAdminUserService{} h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/notanint/password", `{"new_password":"ValidPass123Long"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "notanint") rr := httptest.NewRecorder() h.SetPassword(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("Code = %d, want 404", rr.Code) } if stub.setPasswordCalled { t.Error("service should not be called when ID parsing fails") } } // TestAdminUserHandler_SetPassword_400OnWeakPassword surfaces an // InvalidInputError from the service as 400 with the field name in the body. func TestAdminUserHandler_SetPassword_400OnWeakPassword(t *testing.T) { stub := &stubAdminUserService{ setPasswordErr: &domain.InvalidInputError{Field: "new_password", Message: "too short"}, } h := NewAdminUserHandler(nil, stub, nil) req := createAuthenticatedRequest(http.MethodPost, "/api/admin/users/42/password", `{"new_password":"x"}`, 1, "admin@example.com", "admin") req = addChiURLParam(req, "id", "42") rr := httptest.NewRecorder() h.SetPassword(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("Code = %d, want 400", rr.Code) } if !strings.Contains(rr.Body.String(), "new_password") { t.Errorf("body should include field name; got %s", rr.Body.String()) } }