From e157910dd4408516fc9ac72a7ae0694ae33e06f0 Mon Sep 17 00:00:00 2001 From: GoHorse Deploy Date: Fri, 6 Mar 2026 09:40:49 -0300 Subject: [PATCH] fix: handle duplicate user email errors properly --- .../internal/api/handlers/core_handlers.go | 10 +- .../api/handlers/core_handlers_test.go | 105 +++++++++++++----- .../core/usecases/user/create_user.go | 18 ++- frontend/src/app/dashboard/users/page.tsx | 5 +- frontend/src/lib/__tests__/api.test.ts | 16 +++ frontend/src/lib/api.ts | 24 +++- 6 files changed, 136 insertions(+), 42 deletions(-) diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index ee70c72..3ff33ae 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "net" "net/http" "strconv" @@ -353,7 +354,14 @@ func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { resp, err := h.createUserUC.Execute(ctx, req, tenantID) 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 } diff --git a/backend/internal/api/handlers/core_handlers_test.go b/backend/internal/api/handlers/core_handlers_test.go index 751521f..5c353a2 100644 --- a/backend/internal/api/handlers/core_handlers_test.go +++ b/backend/internal/api/handlers/core_handlers_test.go @@ -78,7 +78,7 @@ func TestRegisterCandidateHandler_Success(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}") 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) { - coreHandlers := createTestCoreHandlers(t, nil, nil) + coreHandlers := createTestCoreHandlers(t, nil, nil, nil) testCases := []struct { name string @@ -139,7 +139,7 @@ func TestRegisterCandidateHandler_MissingFields(t *testing.T) { } func TestLoginHandler_InvalidPayload(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil, nil) + coreHandlers := createTestCoreHandlers(t, nil, nil, nil) body := bytes.NewBufferString("{invalid}") 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 mockRepo := &mockUserRepo{ findByEmailFunc: func(email string) (*entity.User, error) { @@ -171,7 +171,7 @@ func TestLoginHandler_Success(t *testing.T) { // Real UseCase with Mocks loginUC := auth.NewLoginUseCase(mockRepo, mockAuth) - coreHandlers := createTestCoreHandlers(t, nil, loginUC) + coreHandlers := createTestCoreHandlers(t, nil, loginUC, nil) // Request payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"} @@ -203,14 +203,69 @@ func TestLoginHandler_Success(t *testing.T) { if !jwtCookie.HttpOnly { t.Error("Cookie should be HttpOnly") } - if jwtCookie.Value != "mock_token" { - t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value) - } -} - -// createTestCoreHandlers creates handlers with mocks and optional DB -func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { - t.Helper() + if jwtCookie.Value != "mock_token" { + t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value) + } +} + +func TestCreateUserHandler_DuplicateEmailReturnsConflict(t *testing.T) { + mockRepo := &mockUserRepo{ + 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 var auditSvc *services.AuditService @@ -227,14 +282,14 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase credSvc = services.NewCredentialsService(db) } - return handlers.NewCoreHandlers( - loginUC, - (*auth.RegisterCandidateUseCase)(nil), - (*tenant.CreateCompanyUseCase)(nil), - (*user.CreateUserUseCase)(nil), - (*user.ListUsersUseCase)(nil), - (*user.DeleteUserUseCase)(nil), - (*user.UpdateUserUseCase)(nil), + return handlers.NewCoreHandlers( + loginUC, + (*auth.RegisterCandidateUseCase)(nil), + (*tenant.CreateCompanyUseCase)(nil), + createUserUC, + (*user.ListUsersUseCase)(nil), + (*user.DeleteUserUseCase)(nil), + (*user.UpdateUserUseCase)(nil), (*user.UpdatePasswordUseCase)(nil), (*tenant.ListCompaniesUseCase)(nil), nil, // forgotPasswordUC @@ -245,7 +300,7 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase adminSvc, credSvc, ) -} +} func TestCoreHandlers_ListNotifications(t *testing.T) { // Setup DB Mock @@ -256,7 +311,7 @@ func TestCoreHandlers_ListNotifications(t *testing.T) { defer db.Close() // Setup Handlers with DB - handlers := createTestCoreHandlers(t, db, nil) + handlers := createTestCoreHandlers(t, db, nil, nil) // User ID userID := "user-123" @@ -300,7 +355,7 @@ func TestCoreHandlers_Tickets(t *testing.T) { } defer db.Close() - handlers := createTestCoreHandlers(t, db, nil) + handlers := createTestCoreHandlers(t, db, nil, nil) // Mock Insert: user_id, subject, priority mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). @@ -333,7 +388,7 @@ func TestCoreHandlers_Tickets(t *testing.T) { } defer db.Close() - handlers := createTestCoreHandlers(t, db, nil) + handlers := createTestCoreHandlers(t, db, nil, nil) // Mock Select mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)). diff --git a/backend/internal/core/usecases/user/create_user.go b/backend/internal/core/usecases/user/create_user.go index 686deb9..b651ee0 100644 --- a/backend/internal/core/usecases/user/create_user.go +++ b/backend/internal/core/usecases/user/create_user.go @@ -11,6 +11,11 @@ import ( "github.com/rede5/gohorsejobs/backend/internal/utils" ) +var ( + ErrInvalidEmail = errors.New("email inválido") + ErrEmailAlreadyExists = errors.New("email já cadastrado") +) + // isValidEmail validates email format func isValidEmail(email string) bool { 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) { - // 0. Sanitize inputs sanitizer := utils.DefaultSanitizer() input.Name = sanitizer.SanitizeName(input.Name) input.Email = sanitizer.SanitizeEmail(input.Email) - // Validate email format 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) if exists != nil { - return nil, errors.New("email já cadastrado") + return nil, ErrEmailAlreadyExists } - // 2. Hash Password hashed, err := uc.authService.HashPassword(input.Password) if err != nil { return nil, err } - // 3. Create Entity - // Note: We enforce currentTenantID unless it's empty (SuperAdmin context) and input provides one. tenantID := currentTenantID if tenantID == "" && input.TenantID != nil { tenantID = *input.TenantID @@ -67,7 +65,6 @@ func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRe user.Status = *input.Status } - // Assign roles for _, r := range input.Roles { user.AssignRole(entity.Role{Name: r}) } @@ -77,7 +74,6 @@ func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRe return nil, err } - // 4. Return DTO roles := make([]string, len(saved.Roles)) for i, r := range saved.Roles { roles[i] = r.Name diff --git a/frontend/src/app/dashboard/users/page.tsx b/frontend/src/app/dashboard/users/page.tsx index 132956c..1821132 100644 --- a/frontend/src/app/dashboard/users/page.tsx +++ b/frontend/src/app/dashboard/users/page.tsx @@ -130,7 +130,10 @@ export default function AdminUsersPage() { loadUsers(1) } catch (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 { setCreating(false) } diff --git a/frontend/src/lib/__tests__/api.test.ts b/frontend/src/lib/__tests__/api.test.ts index b3a5926..345d2e8 100644 --- a/frontend/src/lib/__tests__/api.test.ts +++ b/frontend/src/lib/__tests__/api.test.ts @@ -211,16 +211,32 @@ describe('API Client', () => { ; (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 401, + headers: { get: jest.fn().mockReturnValue('application/json') }, json: async () => ({ message: '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 () => { ; (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500, + headers: { get: jest.fn().mockReturnValue('application/json') }, json: async () => ({}), }) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 712e0bf..82fecec 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,6 +15,24 @@ function logCrudAction(action: string, entity: string, details?: any) { } } +async function getErrorMessage(response: Response): Promise { + 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 */ @@ -51,8 +69,7 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi throw error; } - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Request failed with status ${response.status}`); + throw new Error(await getErrorMessage(response)); } if (response.status === 204) { @@ -840,8 +857,7 @@ async function backofficeRequest(endpoint: string, options: RequestInit = {}) (error as any).silent = true; throw error; } - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Request failed with status ${response.status}`); + throw new Error(await getErrorMessage(response)); } if (response.status === 204) {