fix: handle duplicate user email errors properly
This commit is contained in:
parent
1fbbe9fe18
commit
e157910dd4
6 changed files with 136 additions and 42 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -208,8 +208,63 @@ func TestLoginHandler_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// createTestCoreHandlers creates handlers with mocks and optional DB
|
||||||
func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase) *handlers.CoreHandlers {
|
func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase, createUserUC *user.CreateUserUseCase) *handlers.CoreHandlers {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Init services if DB provided
|
// Init services if DB provided
|
||||||
|
|
@ -231,7 +286,7 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase
|
||||||
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),
|
||||||
|
|
@ -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`)).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => ({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue