diff --git a/internal/application/commands/request_password_reset_test.go b/internal/application/commands/request_password_reset_test.go new file mode 100644 index 0000000..d1c7329 --- /dev/null +++ b/internal/application/commands/request_password_reset_test.go @@ -0,0 +1,336 @@ +package commands + +import ( + "context" + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/services" + "apocapoc-api/internal/shared/errors" +) + +type mockRequestResetUserRepo struct { + findByEmailFunc func(ctx context.Context, email string) (*entities.User, error) + users map[string]*entities.User +} + +func (m *mockRequestResetUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + if m.findByEmailFunc != nil { + return m.findByEmailFunc(ctx, email) + } + if user, ok := m.users[email]; ok { + return user, nil + } + return nil, errors.ErrNotFound +} + +func (m *mockRequestResetUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockRequestResetUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockRequestResetUserRepo) Create(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockRequestResetUserRepo) Update(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockRequestResetUserRepo) Delete(ctx context.Context, id string) error { + return nil +} + +type mockRequestResetTokenRepo struct { + createFunc func(ctx context.Context, token *entities.PasswordResetToken) error + tokens []*entities.PasswordResetToken +} + +func (m *mockRequestResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error { + if m.createFunc != nil { + return m.createFunc(ctx, token) + } + m.tokens = append(m.tokens, token) + return nil +} + +func (m *mockRequestResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) { + return nil, errors.ErrNotFound +} + +func (m *mockRequestResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error { + return nil +} + +func (m *mockRequestResetTokenRepo) DeleteExpired(ctx context.Context) error { + return nil +} + +type mockRequestResetEmailService struct { + sendFunc func(message services.EmailMessage) error + sentMessages []services.EmailMessage +} + +func (m *mockRequestResetEmailService) Send(message services.EmailMessage) error { + if m.sendFunc != nil { + return m.sendFunc(message) + } + m.sentMessages = append(m.sentMessages, message) + return nil +} + +func TestRequestPasswordResetHandler_Success(t *testing.T) { + user := entities.NewUser("test@example.com", "hash") + user.ID = "user-123" + user.EmailVerified = true + + userRepo := &mockRequestResetUserRepo{ + users: map[string]*entities.User{ + user.Email: user, + }, + } + + tokenRepo := &mockRequestResetTokenRepo{ + tokens: []*entities.PasswordResetToken{}, + } + + emailService := &mockRequestResetEmailService{ + sentMessages: []services.EmailMessage{}, + } + + handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080") + + cmd := RequestPasswordResetCommand{ + Email: user.Email, + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if len(tokenRepo.tokens) != 1 { + t.Fatalf("Expected 1 token created, got %d", len(tokenRepo.tokens)) + } + + createdToken := tokenRepo.tokens[0] + if createdToken.UserID != user.ID { + t.Errorf("Token UserID = %v, want %v", createdToken.UserID, user.ID) + } + + if createdToken.Token == "" { + t.Error("Token string is empty") + } + + if createdToken.ExpiresAt.Before(time.Now()) { + t.Error("Token already expired") + } + + expectedExpiry := time.Now().Add(1 * time.Hour) + diff := createdToken.ExpiresAt.Sub(expectedExpiry) + if diff > time.Minute || diff < -time.Minute { + t.Errorf("Token expiry = %v, expected around %v", createdToken.ExpiresAt, expectedExpiry) + } + + if len(emailService.sentMessages) != 1 { + t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages)) + } + + sentEmail := emailService.sentMessages[0] + if sentEmail.To != user.Email { + t.Errorf("Email To = %v, want %v", sentEmail.To, user.Email) + } + + if sentEmail.Subject != "Password Reset Request" { + t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Password Reset Request") + } + + if !sentEmail.IsHTML { + t.Error("Email should be HTML") + } + + if sentEmail.Body == "" { + t.Error("Email body is empty") + } +} + +func TestRequestPasswordResetHandler_EmptyEmail(t *testing.T) { + handler := NewRequestPasswordResetHandler( + &mockRequestResetUserRepo{users: make(map[string]*entities.User)}, + &mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}}, + &mockRequestResetEmailService{}, + "http://localhost:8080", + ) + + cmd := RequestPasswordResetCommand{ + Email: "", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestRequestPasswordResetHandler_UserNotFound(t *testing.T) { + handler := NewRequestPasswordResetHandler( + &mockRequestResetUserRepo{users: make(map[string]*entities.User)}, + &mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}}, + &mockRequestResetEmailService{}, + "http://localhost:8080", + ) + + cmd := RequestPasswordResetCommand{ + Email: "nonexistent@example.com", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrNotFound { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound) + } +} + +func TestRequestPasswordResetHandler_EmailNotVerified(t *testing.T) { + user := entities.NewUser("test@example.com", "hash") + user.ID = "user-123" + user.EmailVerified = false + + userRepo := &mockRequestResetUserRepo{ + users: map[string]*entities.User{ + user.Email: user, + }, + } + + handler := NewRequestPasswordResetHandler( + userRepo, + &mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}}, + &mockRequestResetEmailService{}, + "http://localhost:8080", + ) + + cmd := RequestPasswordResetCommand{ + Email: user.Email, + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrEmailNotVerified { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified) + } +} + +func TestRequestPasswordResetHandler_TokenCreationFailure(t *testing.T) { + user := entities.NewUser("test@example.com", "hash") + user.ID = "user-123" + user.EmailVerified = true + + userRepo := &mockRequestResetUserRepo{ + users: map[string]*entities.User{ + user.Email: user, + }, + } + + tokenRepo := &mockRequestResetTokenRepo{ + createFunc: func(ctx context.Context, token *entities.PasswordResetToken) error { + return errors.ErrInvalidInput + }, + } + + emailService := &mockRequestResetEmailService{} + + handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080") + + cmd := RequestPasswordResetCommand{ + Email: user.Email, + } + + err := handler.Handle(context.Background(), cmd) + if err == nil { + t.Fatal("Handle() expected error but got nil") + } + + if len(emailService.sentMessages) != 0 { + t.Error("Email should not be sent if token creation fails") + } +} + +func TestRequestPasswordResetHandler_EmailSendFailure(t *testing.T) { + user := entities.NewUser("test@example.com", "hash") + user.ID = "user-123" + user.EmailVerified = true + + userRepo := &mockRequestResetUserRepo{ + users: map[string]*entities.User{ + user.Email: user, + }, + } + + tokenRepo := &mockRequestResetTokenRepo{ + tokens: []*entities.PasswordResetToken{}, + } + + emailService := &mockRequestResetEmailService{ + sendFunc: func(message services.EmailMessage) error { + return errors.ErrInvalidInput + }, + } + + handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080") + + cmd := RequestPasswordResetCommand{ + Email: user.Email, + } + + err := handler.Handle(context.Background(), cmd) + if err == nil { + t.Fatal("Handle() expected error but got nil") + } + + if len(tokenRepo.tokens) != 1 { + t.Error("Token should be created even if email sending fails") + } +} + +func TestRequestPasswordResetHandler_ResetLinkFormat(t *testing.T) { + user := entities.NewUser("test@example.com", "hash") + user.ID = "user-123" + user.EmailVerified = true + + userRepo := &mockRequestResetUserRepo{ + users: map[string]*entities.User{ + user.Email: user, + }, + } + + tokenRepo := &mockRequestResetTokenRepo{ + tokens: []*entities.PasswordResetToken{}, + } + + emailService := &mockRequestResetEmailService{ + sentMessages: []services.EmailMessage{}, + } + + appURL := "https://myapp.com" + handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, appURL) + + cmd := RequestPasswordResetCommand{ + Email: user.Email, + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if len(emailService.sentMessages) != 1 { + t.Fatal("Expected 1 email sent") + } + + sentEmail := emailService.sentMessages[0] + if sentEmail.Body == "" { + t.Fatal("Email body is empty") + } +} diff --git a/internal/application/queries/login_user_test.go b/internal/application/queries/login_user_test.go index c2edda9..e538de2 100644 --- a/internal/application/queries/login_user_test.go +++ b/internal/application/queries/login_user_test.go @@ -90,10 +90,6 @@ func TestLoginUserHandler_Success(t *testing.T) { if result.Email != "test@example.com" { t.Errorf("Email = %v, want %v", result.Email, "test@example.com") } - - if result.Timezone != "UTC" { - t.Errorf("Timezone = %v, want %v", result.Timezone, "UTC") - } } func TestLoginUserHandler_EmptyEmail(t *testing.T) { diff --git a/internal/infrastructure/http/auth_integration_test.go b/internal/infrastructure/http/auth_integration_test.go index 4848ef5..9f71b05 100644 --- a/internal/infrastructure/http/auth_integration_test.go +++ b/internal/infrastructure/http/auth_integration_test.go @@ -13,7 +13,6 @@ func TestAuthFlow(t *testing.T) { reqBody := RegisterRequest{ Email: "test@example.com", Password: "Password123!", - Timezone: "UTC", } rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") @@ -37,7 +36,6 @@ func TestAuthFlow(t *testing.T) { reqBody := RegisterRequest{ Email: "duplicate@example.com", Password: "Password123!", - Timezone: "UTC", } makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") @@ -53,7 +51,6 @@ func TestAuthFlow(t *testing.T) { reqBody := RegisterRequest{ Email: "invalid-email", Password: "Password123!", - Timezone: "UTC", } rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") @@ -67,7 +64,6 @@ func TestAuthFlow(t *testing.T) { reqBody := RegisterRequest{ Email: "short@example.com", Password: "123", - Timezone: "UTC", } rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") @@ -81,7 +77,6 @@ func TestAuthFlow(t *testing.T) { registerBody := RegisterRequest{ Email: "login@example.com", Password: "Password123!", - Timezone: "UTC", } makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") @@ -108,7 +103,6 @@ func TestAuthFlow(t *testing.T) { registerBody := RegisterRequest{ Email: "wrongpass@example.com", Password: "Password123!", - Timezone: "UTC", } makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index a7efdb6..e01b3a3 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -120,7 +120,6 @@ func registerAndLogin(t *testing.T, router http.Handler, email, password string) registerBody := RegisterRequest{ Email: email, Password: password, - Timezone: "UTC", } makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "") diff --git a/internal/infrastructure/persistence/sqlite/migrations_test.go b/internal/infrastructure/persistence/sqlite/migrations_test.go index 77ec86d..89711e3 100644 --- a/internal/infrastructure/persistence/sqlite/migrations_test.go +++ b/internal/infrastructure/persistence/sqlite/migrations_test.go @@ -43,7 +43,7 @@ func TestUsersTableSchema(t *testing.T) { t.Fatalf("RunMigrations failed: %v", err) } - columns := []string{"id", "email", "password_hash", "timezone", "created_at", "updated_at"} + columns := []string{"id", "email", "password_hash", "email_verified", "email_verification_token", "email_verification_expiry", "created_at", "updated_at"} for _, col := range columns { query := "SELECT " + col + " FROM users LIMIT 0" rows, err := db.Query(query) diff --git a/internal/infrastructure/persistence/sqlite/user_repository_test.go b/internal/infrastructure/persistence/sqlite/user_repository_test.go index 93b54b4..fa64cb9 100644 --- a/internal/infrastructure/persistence/sqlite/user_repository_test.go +++ b/internal/infrastructure/persistence/sqlite/user_repository_test.go @@ -111,7 +111,6 @@ func TestUserRepositoryFindByID(t *testing.T) { if found.Email != user.Email { t.Errorf("Expected email %s, got %s", user.Email, found.Email) } - } } func TestUserRepositoryFindByIDNotFound(t *testing.T) { @@ -207,7 +206,6 @@ func TestUserRepositoryUpdate(t *testing.T) { if found.Email != "updated@example.com" { t.Errorf("Expected email updated@example.com, got %s", found.Email) } - } } func TestUserRepositoryUpdateNotFound(t *testing.T) { diff --git a/internal/shared/validation/validator_test.go b/internal/shared/validation/validator_test.go index 4e7a831..57c8805 100644 --- a/internal/shared/validation/validator_test.go +++ b/internal/shared/validation/validator_test.go @@ -118,77 +118,55 @@ func TestValidateRegistration(t *testing.T) { name string email string password string - timezone string wantErr bool }{ { "valid registration", "user@example.com", "Passw0rd!", - "UTC", false, }, { "valid with complex email", "user.name+tag@example.co.uk", "MyS3cur3P@ss", - "Europe/Madrid", false, }, { "invalid email", "invalid-email", "Passw0rd!", - "UTC", true, }, { "invalid password", "user@example.com", "weak", - "UTC", - true, - }, - { - "invalid timezone", - "user@example.com", - "Passw0rd!", - "InvalidTZ", true, }, { "all invalid", "not-an-email", "weak", - "bad-tz", true, }, { "empty email", "", "Passw0rd!", - "UTC", true, }, { "empty password", "user@example.com", "", - "UTC", - true, - }, - { - "empty timezone", - "user@example.com", - "Passw0rd!", - "", true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateRegistration(tt.email, tt.password, tt.timezone) + err := ValidateRegistration(tt.email, tt.password) if (err != nil) != tt.wantErr { t.Errorf("ValidateRegistration() error = %v, wantErr %v", err, tt.wantErr) }