fix: handle duplicate user email errors properly

This commit is contained in:
GoHorse Deploy 2026-03-06 09:40:49 -03:00
parent 1fbbe9fe18
commit e157910dd4
6 changed files with 136 additions and 42 deletions

View file

@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@ -353,7 +354,14 @@ func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) {
resp, err := h.createUserUC.Execute(ctx, req, tenantID) resp, err := h.createUserUC.Execute(ctx, req, tenantID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) statusCode := http.StatusInternalServerError
switch {
case errors.Is(err, user.ErrInvalidEmail):
statusCode = http.StatusBadRequest
case errors.Is(err, user.ErrEmailAlreadyExists):
statusCode = http.StatusConflict
}
http.Error(w, err.Error(), statusCode)
return return
} }

View file

@ -78,7 +78,7 @@ func TestRegisterCandidateHandler_Success(t *testing.T) {
} }
func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) {
coreHandlers := createTestCoreHandlers(t, nil, nil) coreHandlers := createTestCoreHandlers(t, nil, nil, nil)
body := bytes.NewBufferString("{invalid json}") body := bytes.NewBufferString("{invalid json}")
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body)
@ -93,7 +93,7 @@ func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) {
} }
func TestRegisterCandidateHandler_MissingFields(t *testing.T) { func TestRegisterCandidateHandler_MissingFields(t *testing.T) {
coreHandlers := createTestCoreHandlers(t, nil, nil) coreHandlers := createTestCoreHandlers(t, nil, nil, nil)
testCases := []struct { testCases := []struct {
name string name string
@ -139,7 +139,7 @@ func TestRegisterCandidateHandler_MissingFields(t *testing.T) {
} }
func TestLoginHandler_InvalidPayload(t *testing.T) { func TestLoginHandler_InvalidPayload(t *testing.T) {
coreHandlers := createTestCoreHandlers(t, nil, nil) coreHandlers := createTestCoreHandlers(t, nil, nil, nil)
body := bytes.NewBufferString("{invalid}") body := bytes.NewBufferString("{invalid}")
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body)
@ -153,7 +153,7 @@ func TestLoginHandler_InvalidPayload(t *testing.T) {
} }
} }
func TestLoginHandler_Success(t *testing.T) { func TestLoginHandler_Success(t *testing.T) {
// Mocks // Mocks
mockRepo := &mockUserRepo{ mockRepo := &mockUserRepo{
findByEmailFunc: func(email string) (*entity.User, error) { findByEmailFunc: func(email string) (*entity.User, error) {
@ -171,7 +171,7 @@ func TestLoginHandler_Success(t *testing.T) {
// Real UseCase with Mocks // Real UseCase with Mocks
loginUC := auth.NewLoginUseCase(mockRepo, mockAuth) loginUC := auth.NewLoginUseCase(mockRepo, mockAuth)
coreHandlers := createTestCoreHandlers(t, nil, loginUC) coreHandlers := createTestCoreHandlers(t, nil, loginUC, nil)
// Request // Request
payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"} payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"}
@ -203,14 +203,69 @@ func TestLoginHandler_Success(t *testing.T) {
if !jwtCookie.HttpOnly { if !jwtCookie.HttpOnly {
t.Error("Cookie should be HttpOnly") t.Error("Cookie should be HttpOnly")
} }
if jwtCookie.Value != "mock_token" { if jwtCookie.Value != "mock_token" {
t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value) t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value)
} }
} }
// createTestCoreHandlers creates handlers with mocks and optional DB func TestCreateUserHandler_DuplicateEmailReturnsConflict(t *testing.T) {
func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { mockRepo := &mockUserRepo{
t.Helper() findByEmailFunc: func(email string) (*entity.User, error) {
existing := entity.NewUser("existing-id", "tenant-1", "Existing User", email)
return existing, nil
},
}
mockAuth := &mockAuthService{}
createUserUC := user.NewCreateUserUseCase(mockRepo, mockAuth)
coreHandlers := createTestCoreHandlers(t, nil, nil, createUserUC)
payload := dto.CreateUserRequest{
Name: "Duplicate User",
Email: "duplicate@example.com",
Password: "123456",
Roles: []string{"admin"},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), middleware.ContextRoles, []string{"superadmin"}))
rec := httptest.NewRecorder()
coreHandlers.CreateUser(rec, req)
if rec.Code != http.StatusConflict {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusConflict, rec.Code, rec.Body.String())
}
}
func TestCreateUserHandler_InvalidEmailReturnsBadRequest(t *testing.T) {
mockRepo := &mockUserRepo{}
mockAuth := &mockAuthService{}
createUserUC := user.NewCreateUserUseCase(mockRepo, mockAuth)
coreHandlers := createTestCoreHandlers(t, nil, nil, createUserUC)
payload := dto.CreateUserRequest{
Name: "Invalid User",
Email: "invalid-email",
Password: "123456",
Roles: []string{"admin"},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), middleware.ContextRoles, []string{"superadmin"}))
rec := httptest.NewRecorder()
coreHandlers.CreateUser(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
// createTestCoreHandlers creates handlers with mocks and optional DB
func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase, createUserUC *user.CreateUserUseCase) *handlers.CoreHandlers {
t.Helper()
// Init services if DB provided // Init services if DB provided
var auditSvc *services.AuditService var auditSvc *services.AuditService
@ -227,14 +282,14 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase
credSvc = services.NewCredentialsService(db) credSvc = services.NewCredentialsService(db)
} }
return handlers.NewCoreHandlers( return handlers.NewCoreHandlers(
loginUC, loginUC,
(*auth.RegisterCandidateUseCase)(nil), (*auth.RegisterCandidateUseCase)(nil),
(*tenant.CreateCompanyUseCase)(nil), (*tenant.CreateCompanyUseCase)(nil),
(*user.CreateUserUseCase)(nil), createUserUC,
(*user.ListUsersUseCase)(nil), (*user.ListUsersUseCase)(nil),
(*user.DeleteUserUseCase)(nil), (*user.DeleteUserUseCase)(nil),
(*user.UpdateUserUseCase)(nil), (*user.UpdateUserUseCase)(nil),
(*user.UpdatePasswordUseCase)(nil), (*user.UpdatePasswordUseCase)(nil),
(*tenant.ListCompaniesUseCase)(nil), (*tenant.ListCompaniesUseCase)(nil),
nil, // forgotPasswordUC nil, // forgotPasswordUC
@ -245,7 +300,7 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase
adminSvc, adminSvc,
credSvc, credSvc,
) )
} }
func TestCoreHandlers_ListNotifications(t *testing.T) { func TestCoreHandlers_ListNotifications(t *testing.T) {
// Setup DB Mock // Setup DB Mock
@ -256,7 +311,7 @@ func TestCoreHandlers_ListNotifications(t *testing.T) {
defer db.Close() defer db.Close()
// Setup Handlers with DB // Setup Handlers with DB
handlers := createTestCoreHandlers(t, db, nil) handlers := createTestCoreHandlers(t, db, nil, nil)
// User ID // User ID
userID := "user-123" userID := "user-123"
@ -300,7 +355,7 @@ func TestCoreHandlers_Tickets(t *testing.T) {
} }
defer db.Close() defer db.Close()
handlers := createTestCoreHandlers(t, db, nil) handlers := createTestCoreHandlers(t, db, nil, nil)
// Mock Insert: user_id, subject, priority // Mock Insert: user_id, subject, priority
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)).
@ -333,7 +388,7 @@ func TestCoreHandlers_Tickets(t *testing.T) {
} }
defer db.Close() defer db.Close()
handlers := createTestCoreHandlers(t, db, nil) handlers := createTestCoreHandlers(t, db, nil, nil)
// Mock Select // Mock Select
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)). mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)).

View file

@ -11,6 +11,11 @@ import (
"github.com/rede5/gohorsejobs/backend/internal/utils" "github.com/rede5/gohorsejobs/backend/internal/utils"
) )
var (
ErrInvalidEmail = errors.New("email inválido")
ErrEmailAlreadyExists = errors.New("email já cadastrado")
)
// isValidEmail validates email format // isValidEmail validates email format
func isValidEmail(email string) bool { func isValidEmail(email string) bool {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
@ -30,31 +35,24 @@ func NewCreateUserUseCase(uRepo ports.UserRepository, auth ports.AuthService) *C
} }
func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRequest, currentTenantID string) (*dto.UserResponse, error) { func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRequest, currentTenantID string) (*dto.UserResponse, error) {
// 0. Sanitize inputs
sanitizer := utils.DefaultSanitizer() sanitizer := utils.DefaultSanitizer()
input.Name = sanitizer.SanitizeName(input.Name) input.Name = sanitizer.SanitizeName(input.Name)
input.Email = sanitizer.SanitizeEmail(input.Email) input.Email = sanitizer.SanitizeEmail(input.Email)
// Validate email format
if input.Email == "" || !isValidEmail(input.Email) { if input.Email == "" || !isValidEmail(input.Email) {
return nil, errors.New("email inválido") return nil, ErrInvalidEmail
} }
// 1. Validate Email Uniqueness (within tenant? or global?)
// Usually email is unique global or per tenant. Let's assume unique.
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email) exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
if exists != nil { if exists != nil {
return nil, errors.New("email já cadastrado") return nil, ErrEmailAlreadyExists
} }
// 2. Hash Password
hashed, err := uc.authService.HashPassword(input.Password) hashed, err := uc.authService.HashPassword(input.Password)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 3. Create Entity
// Note: We enforce currentTenantID unless it's empty (SuperAdmin context) and input provides one.
tenantID := currentTenantID tenantID := currentTenantID
if tenantID == "" && input.TenantID != nil { if tenantID == "" && input.TenantID != nil {
tenantID = *input.TenantID tenantID = *input.TenantID
@ -67,7 +65,6 @@ func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRe
user.Status = *input.Status user.Status = *input.Status
} }
// Assign roles
for _, r := range input.Roles { for _, r := range input.Roles {
user.AssignRole(entity.Role{Name: r}) user.AssignRole(entity.Role{Name: r})
} }
@ -77,7 +74,6 @@ func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRe
return nil, err return nil, err
} }
// 4. Return DTO
roles := make([]string, len(saved.Roles)) roles := make([]string, len(saved.Roles))
for i, r := range saved.Roles { for i, r := range saved.Roles {
roles[i] = r.Name roles[i] = r.Name

View file

@ -130,7 +130,10 @@ export default function AdminUsersPage() {
loadUsers(1) loadUsers(1)
} catch (error) { } catch (error) {
console.error("[USER_FLOW] Error creating user:", error) console.error("[USER_FLOW] Error creating user:", error)
toast.error(t('admin.users.messages.create_error')) const message = error instanceof Error && error.message
? error.message
: t('admin.users.messages.create_error')
toast.error(message)
} finally { } finally {
setCreating(false) setCreating(false)
} }

View file

@ -211,16 +211,32 @@ describe('API Client', () => {
; (global.fetch as jest.Mock).mockResolvedValueOnce({ ; (global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false, ok: false,
status: 401, status: 401,
headers: { get: jest.fn().mockReturnValue('application/json') },
json: async () => ({ message: 'Unauthorized' }), json: async () => ({ message: 'Unauthorized' }),
}) })
await expect(usersApi.list({ page: 1, limit: 10 })).rejects.toThrow('Unauthorized') await expect(usersApi.list({ page: 1, limit: 10 })).rejects.toThrow('Unauthorized')
}) })
it('should throw plain text error body when API does not return JSON', async () => {
; (global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 409,
headers: { get: jest.fn().mockReturnValue('text/plain; charset=utf-8') },
text: async () => 'email já cadastrado',
json: async () => {
throw new Error('Should not parse JSON for plain text responses')
},
})
await expect(usersApi.create({})).rejects.toThrow('email já cadastrado')
})
it('should throw generic error when no message', async () => { it('should throw generic error when no message', async () => {
; (global.fetch as jest.Mock).mockResolvedValueOnce({ ; (global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false, ok: false,
status: 500, status: 500,
headers: { get: jest.fn().mockReturnValue('application/json') },
json: async () => ({}), json: async () => ({}),
}) })

View file

@ -15,6 +15,24 @@ function logCrudAction(action: string, entity: string, details?: any) {
} }
} }
async function getErrorMessage(response: Response): Promise<string> {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const errorData = await response.json().catch(() => ({}));
if (typeof errorData?.message === "string" && errorData.message.trim()) {
return errorData.message;
}
} else {
const errorText = await response.text().catch(() => "");
if (errorText.trim()) {
return errorText;
}
}
return `Request failed with status ${response.status}`;
}
/** /**
* Generic API Request Wrapper * Generic API Request Wrapper
*/ */
@ -51,8 +69,7 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
throw error; throw error;
} }
const errorData = await response.json().catch(() => ({})); throw new Error(await getErrorMessage(response));
throw new Error(errorData.message || `Request failed with status ${response.status}`);
} }
if (response.status === 204) { if (response.status === 204) {
@ -840,8 +857,7 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
(error as any).silent = true; (error as any).silent = true;
throw error; throw error;
} }
const errorData = await response.json().catch(() => ({})); throw new Error(await getErrorMessage(response));
throw new Error(errorData.message || `Request failed with status ${response.status}`);
} }
if (response.status === 204) { if (response.status === 204) {