Ctrl K
ComunidadGuías…

internal/handler/admin_user_handler_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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())
	}
}