diff --git a/cmd/api/main.go b/cmd/api/main.go index 7b6b65c..2fcfa14 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -95,7 +95,7 @@ func main() { jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) passwordHasher := crypto.NewBcryptHasher() - var emailService services.EmailService + var emailService services.EmailService = &services.NoOpEmailService{} if cfg.SMTPHost != "" { smtpPort, err := strconv.Atoi(cfg.SMTPPort) if err != nil { diff --git a/internal/application/commands/register_user.go b/internal/application/commands/register_user.go index 6c32839..eed445b 100644 --- a/internal/application/commands/register_user.go +++ b/internal/application/commands/register_user.go @@ -73,7 +73,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman user := entities.NewUser(cmd.Email, hashedPassword) emailVerificationRequired := false - if h.emailService != nil { + if h.emailService.IsEnabled() { token, err := h.generateVerificationToken() if err != nil { return nil, fmt.Errorf("failed to generate verification token: %w", err) diff --git a/internal/application/commands/register_user_test.go b/internal/application/commands/register_user_test.go index e0c8593..8c4bbc6 100644 --- a/internal/application/commands/register_user_test.go +++ b/internal/application/commands/register_user_test.go @@ -2,6 +2,7 @@ package commands import ( "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/domain/services" "apocapoc-api/internal/shared/pagination" "context" "errors" @@ -71,7 +72,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -88,7 +89,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { } if result.EmailVerificationRequired { - t.Error("expected email verification to not be required when emailService is nil") + t.Error("expected email verification to not be required when email is disabled") } if createdUser == nil { @@ -103,7 +104,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { func TestRegisterUserHandler_InvalidEmail(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) tests := []struct { name string @@ -135,7 +136,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) { func TestRegisterUserHandler_InvalidPassword(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) tests := []struct { name string @@ -174,7 +175,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -195,7 +196,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) { return "", expectedErr }, } - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -216,7 +217,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -232,7 +233,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { func TestRegisterUserHandler_EdgeCases(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false) tests := []struct { name string @@ -285,7 +286,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { func TestRegisterUserHandler_ClosedRegistration(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false) + handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "closed", false) cmd := RegisterUserCommand{ Email: "test@example.com", diff --git a/internal/application/commands/request_password_reset_test.go b/internal/application/commands/request_password_reset_test.go index 563f880..e79f749 100644 --- a/internal/application/commands/request_password_reset_test.go +++ b/internal/application/commands/request_password_reset_test.go @@ -89,6 +89,10 @@ func (m *mockRequestResetEmailService) HealthCheck() error { return nil } +func (m *mockRequestResetEmailService) IsEnabled() bool { + return true +} + func TestRequestPasswordResetHandler_Success(t *testing.T) { user := entities.NewUser("test@example.com", "hash") user.ID = "user-123" diff --git a/internal/application/commands/verify_email_test.go b/internal/application/commands/verify_email_test.go index f5d3008..2f40eb8 100644 --- a/internal/application/commands/verify_email_test.go +++ b/internal/application/commands/verify_email_test.go @@ -69,6 +69,10 @@ func (m *mockEmailService) HealthCheck() error { return nil } +func (m *mockEmailService) IsEnabled() bool { + return true +} + func TestVerifyEmailHandler_Success(t *testing.T) { token := "valid-token" expiry := time.Now().Add(24 * time.Hour) diff --git a/internal/domain/services/email_service.go b/internal/domain/services/email_service.go index 4deb142..3e27d06 100644 --- a/internal/domain/services/email_service.go +++ b/internal/domain/services/email_service.go @@ -10,4 +10,11 @@ type EmailMessage struct { type EmailService interface { Send(message EmailMessage) error HealthCheck() error + IsEnabled() bool } + +type NoOpEmailService struct{} + +func (n *NoOpEmailService) Send(_ EmailMessage) error { return nil } +func (n *NoOpEmailService) HealthCheck() error { return nil } +func (n *NoOpEmailService) IsEnabled() bool { return false } diff --git a/internal/domain/services/streak.go b/internal/domain/services/streak.go index cd5ca2d..09503be 100644 --- a/internal/domain/services/streak.go +++ b/internal/domain/services/streak.go @@ -49,7 +49,8 @@ func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEnt } func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time { - start := time.Date(habit.CreatedAt.Year(), habit.CreatedAt.Month(), habit.CreatedAt.Day(), 0, 0, 0, 0, time.UTC) + createdUTC := habit.CreatedAt.UTC() + start := time.Date(createdUTC.Year(), createdUTC.Month(), createdUTC.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) freq := string(habit.Frequency) diff --git a/internal/infrastructure/email/smtp_service.go b/internal/infrastructure/email/smtp_service.go index 4fd0257..6e22e25 100644 --- a/internal/infrastructure/email/smtp_service.go +++ b/internal/infrastructure/email/smtp_service.go @@ -107,6 +107,10 @@ func (s *SMTPService) GetConfig() SMTPConfig { return s.config } +func (s *SMTPService) IsEnabled() bool { + return true +} + func (s *SMTPService) HealthCheck() error { dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password) dialer.TLSConfig = &tls.Config{ diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 92e5b17..5b58821 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -11,6 +11,7 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" + "apocapoc-api/internal/domain/services" "apocapoc-api/internal/i18n" "apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/crypto" @@ -43,14 +44,15 @@ func setupTestServer(t *testing.T) *TestServer { refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db) - registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false) + noOpEmail := &services.NoOpEmailService{} + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, noOpEmail, "", "open", false) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo) - verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false) - resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "") - requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "") + verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, noOpEmail, false) + resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, noOpEmail, "") + requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, noOpEmail, "") resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index edf4e1e..7ee5ca8 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -30,6 +30,9 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa AllowCredentials: true, })) + r.Get("/docs", httpSwagger.Handler( + httpSwagger.URL("/api/v1/docs/doc.json"), + )) r.Get("/api/v1/docs", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/api/v1/docs/index.html", http.StatusMovedPermanently) })