4 Commits

Author SHA1 Message Date
david a07d033e93 Add NoOp email service, /docs shortcut and fix streak timezone bug
Replace nil email service pattern with NoOpEmailService (Null Object)
to eliminate nil pointer panics across all handlers.

Add /docs route as a shortcut to Swagger UI.

Fix streak calculation returning 0 when server timezone differs from
UTC — CreatedAt was not converted to UTC before date extraction.
2026-03-27 00:32:10 +01:00
david 67fc508384 Fix import order 2026-03-27 00:04:15 +01:00
david 92fe617f73 Fix typed nil panic when SMTP is not configured 2026-03-27 00:00:02 +01:00
david 94c5c30d09 feat: allow editing frequency and target_value on habits
- Add frequency to UpdateHabitRequest (was immutable, now editable)
- Validate frequency + specific_days/dates coherence on update
- Keep type and is_negative immutable (they change entry semantics)
- Remove completion_rate references from swagger and README
2026-03-08 00:14:25 +01:00
21 changed files with 168 additions and 52 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ REST API for habit tracking built with Go. Self-hosted alternative for developer
- Multiple habit types: Boolean, Counter, Value - Multiple habit types: Boolean, Counter, Value
- Flexible scheduling: Daily, Weekly, Monthly - Flexible scheduling: Daily, Weekly, Monthly
- Statistics: Streaks, completion rates, progress tracking - Statistics: Streaks and completions tracking
- JWT authentication, rate limiting, optional email verification - JWT authentication, rate limiting, optional email verification
- Registration modes: Open or closed - Registration modes: Open or closed
- SQLite database (single file) - SQLite database (single file)
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/i18n" "apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/backup" "apocapoc-api/internal/infrastructure/backup"
@@ -94,7 +95,7 @@ func main() {
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
passwordHasher := crypto.NewBcryptHasher() passwordHasher := crypto.NewBcryptHasher()
var emailService *email.SMTPService var emailService services.EmailService = &services.NoOpEmailService{}
if cfg.SMTPHost != "" { if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort) smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil { if err != nil {
+4 -1
View File
@@ -1172,7 +1172,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "Get statistics for a specific habit including streaks and completion rates", "description": "Get statistics for a specific habit including streaks and completions",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1800,6 +1800,9 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
+4 -1
View File
@@ -1164,7 +1164,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "Get statistics for a specific habit including streaks and completion rates", "description": "Get statistics for a specific habit including streaks and completions",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1792,6 +1792,9 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
+3 -2
View File
@@ -268,6 +268,8 @@ definitions:
type: boolean type: boolean
description: description:
type: string type: string
frequency:
$ref: '#/definitions/value_objects.Frequency'
name: name:
type: string type: string
specific_dates: specific_dates:
@@ -1189,8 +1191,7 @@ paths:
- system - system
/stats/habits/{id}: /stats/habits/{id}:
get: get:
description: Get statistics for a specific habit including streaks and completion description: Get statistics for a specific habit including streaks and completions
rates
parameters: parameters:
- description: Habit ID - description: Habit ID
in: path in: path
@@ -73,7 +73,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
user := entities.NewUser(cmd.Email, hashedPassword) user := entities.NewUser(cmd.Email, hashedPassword)
emailVerificationRequired := false emailVerificationRequired := false
if h.emailService != nil { if h.emailService.IsEnabled() {
token, err := h.generateVerificationToken() token, err := h.generateVerificationToken()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err) return nil, fmt.Errorf("failed to generate verification token: %w", err)
@@ -2,6 +2,7 @@ package commands
import ( import (
"apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"errors" "errors"
@@ -71,7 +72,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
}, },
} }
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -88,7 +89,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
} }
if result.EmailVerificationRequired { 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 { if createdUser == nil {
@@ -103,7 +104,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
func TestRegisterUserHandler_InvalidEmail(t *testing.T) { func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
tests := []struct { tests := []struct {
name string name string
@@ -135,7 +136,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
func TestRegisterUserHandler_InvalidPassword(t *testing.T) { func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
tests := []struct { tests := []struct {
name string name string
@@ -174,7 +175,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
}, },
} }
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -195,7 +196,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
return "", expectedErr return "", expectedErr
}, },
} }
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -216,7 +217,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
}, },
} }
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -232,7 +233,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
func TestRegisterUserHandler_EdgeCases(t *testing.T) { func TestRegisterUserHandler_EdgeCases(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
tests := []struct { tests := []struct {
name string name string
@@ -285,7 +286,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) { func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false) handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "closed", false)
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -89,6 +89,10 @@ func (m *mockRequestResetEmailService) HealthCheck() error {
return nil return nil
} }
func (m *mockRequestResetEmailService) IsEnabled() bool {
return true
}
func TestRequestPasswordResetHandler_Success(t *testing.T) { func TestRequestPasswordResetHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "hash") user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123" user.ID = "user-123"
+19 -4
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
) )
@@ -13,10 +14,11 @@ type UpdateHabitCommand struct {
UserID string UserID string
Name string Name string
Description string Description string
CarryOver bool Frequency value_objects.Frequency
TargetValue *float64
SpecificDays []int SpecificDays []int
SpecificDates []int SpecificDates []int
CarryOver bool
TargetValue *float64
} }
type UpdateHabitHandler struct { type UpdateHabitHandler struct {
@@ -34,6 +36,18 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
return errors.ErrInvalidInput return errors.ErrInvalidInput
} }
if !cmd.Frequency.IsValid() {
return errors.ErrInvalidInput
}
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
return errors.ErrInvalidInput
}
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
return errors.ErrInvalidInput
}
habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID) habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID)
if err != nil { if err != nil {
return err return err
@@ -49,10 +63,11 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
habit.Name = cmd.Name habit.Name = cmd.Name
habit.Description = cmd.Description habit.Description = cmd.Description
habit.CarryOver = cmd.CarryOver habit.Frequency = cmd.Frequency
habit.TargetValue = cmd.TargetValue
habit.SpecificDays = cmd.SpecificDays habit.SpecificDays = cmd.SpecificDays
habit.SpecificDates = cmd.SpecificDates habit.SpecificDates = cmd.SpecificDates
habit.CarryOver = cmd.CarryOver
habit.TargetValue = cmd.TargetValue
return h.habitRepo.Update(ctx, habit) return h.habitRepo.Update(ctx, habit)
} }
@@ -48,9 +48,10 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
UserID: "user-123", UserID: "user-123",
Name: "Morning Exercise", Name: "Morning Exercise",
Description: "Updated description", Description: "Updated description",
Frequency: value_objects.FrequencyWeekly,
SpecificDays: []int{1, 3, 5},
CarryOver: true, CarryOver: true,
TargetValue: &newTargetValue, TargetValue: &newTargetValue,
SpecificDays: []int{1, 3, 5},
} }
err := handler.Handle(context.Background(), cmd) err := handler.Handle(context.Background(), cmd)
@@ -67,6 +68,14 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description) t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description)
} }
if habitRepo.updatedHabit.Frequency != value_objects.FrequencyWeekly {
t.Errorf("Expected frequency WEEKLY, got %s", habitRepo.updatedHabit.Frequency)
}
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
}
if !habitRepo.updatedHabit.CarryOver { if !habitRepo.updatedHabit.CarryOver {
t.Error("Expected CarryOver to be true") t.Error("Expected CarryOver to be true")
} }
@@ -74,10 +83,6 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 { if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 {
t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue) t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue)
} }
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
}
} }
func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
@@ -88,9 +93,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
handler := NewUpdateHabitHandler(habitRepo) handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{ cmd := UpdateHabitCommand{
HabitID: "non-existent", HabitID: "non-existent",
UserID: "user-123", UserID: "user-123",
Name: "Exercise", Name: "Exercise",
Frequency: value_objects.FrequencyDaily,
} }
err := handler.Handle(context.Background(), cmd) err := handler.Handle(context.Background(), cmd)
@@ -111,9 +117,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
handler := NewUpdateHabitHandler(habitRepo) handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{ cmd := UpdateHabitCommand{
HabitID: "habit-1", HabitID: "habit-1",
UserID: "user-456", // Different user UserID: "user-456", // Different user
Name: "Exercise", Name: "Exercise",
Frequency: value_objects.FrequencyDaily,
} }
err := handler.Handle(context.Background(), cmd) err := handler.Handle(context.Background(), cmd)
@@ -135,9 +142,10 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) {
handler := NewUpdateHabitHandler(habitRepo) handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{ cmd := UpdateHabitCommand{
HabitID: "habit-1", HabitID: "habit-1",
UserID: "user-123", UserID: "user-123",
Name: "Updated Exercise", Name: "Updated Exercise",
Frequency: value_objects.FrequencyDaily,
} }
err := handler.Handle(context.Background(), cmd) err := handler.Handle(context.Background(), cmd)
@@ -158,9 +166,10 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
handler := NewUpdateHabitHandler(habitRepo) handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{ cmd := UpdateHabitCommand{
HabitID: "habit-1", HabitID: "habit-1",
UserID: "user-123", UserID: "user-123",
Name: "", // Empty name Name: "", // Empty name
Frequency: value_objects.FrequencyDaily,
} }
err := handler.Handle(context.Background(), cmd) err := handler.Handle(context.Background(), cmd)
@@ -169,3 +178,57 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
t.Errorf("Expected ErrInvalidInput for empty name, got %v", err) t.Errorf("Expected ErrInvalidInput for empty name, got %v", err)
} }
} }
func TestUpdateHabitHandler_InvalidFrequency(t *testing.T) {
habitRepo := &mockHabitRepoForUpdate{}
handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{
HabitID: "habit-1",
UserID: "user-123",
Name: "Exercise",
Frequency: "INVALID",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput for invalid frequency, got %v", err)
}
}
func TestUpdateHabitHandler_WeeklyRequiresSpecificDays(t *testing.T) {
habitRepo := &mockHabitRepoForUpdate{}
handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{
HabitID: "habit-1",
UserID: "user-123",
Name: "Exercise",
Frequency: value_objects.FrequencyWeekly,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput for weekly without specific days, got %v", err)
}
}
func TestUpdateHabitHandler_MonthlyRequiresSpecificDates(t *testing.T) {
habitRepo := &mockHabitRepoForUpdate{}
handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{
HabitID: "habit-1",
UserID: "user-123",
Name: "Exercise",
Frequency: value_objects.FrequencyMonthly,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput for monthly without specific dates, got %v", err)
}
}
@@ -69,6 +69,10 @@ func (m *mockEmailService) HealthCheck() error {
return nil return nil
} }
func (m *mockEmailService) IsEnabled() bool {
return true
}
func TestVerifyEmailHandler_Success(t *testing.T) { func TestVerifyEmailHandler_Success(t *testing.T) {
token := "valid-token" token := "valid-token"
expiry := time.Now().Add(24 * time.Hour) expiry := time.Now().Add(24 * time.Hour)
@@ -10,4 +10,11 @@ type EmailMessage struct {
type EmailService interface { type EmailService interface {
Send(message EmailMessage) error Send(message EmailMessage) error
HealthCheck() 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 }
+2 -1
View File
@@ -49,7 +49,8 @@ func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEnt
} }
func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time { 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) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
freq := string(habit.Frequency) freq := string(habit.Frequency)
@@ -107,6 +107,10 @@ func (s *SMTPService) GetConfig() SMTPConfig {
return s.config return s.config
} }
func (s *SMTPService) IsEnabled() bool {
return true
}
func (s *SMTPService) HealthCheck() error { func (s *SMTPService) HealthCheck() error {
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password) dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{ dialer.TLSConfig = &tls.Config{
+7 -6
View File
@@ -20,12 +20,13 @@ type CreateHabitRequest struct {
} }
type UpdateHabitRequest struct { type UpdateHabitRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
SpecificDays []int `json:"specific_days,omitempty"` Frequency value_objects.Frequency `json:"frequency"`
SpecificDates []int `json:"specific_dates,omitempty"` SpecificDays []int `json:"specific_days,omitempty"`
CarryOver bool `json:"carry_over"` SpecificDates []int `json:"specific_dates,omitempty"`
TargetValue *float64 `json:"target_value,omitempty"` CarryOver bool `json:"carry_over"`
TargetValue *float64 `json:"target_value,omitempty"`
} }
type HabitResponse struct { type HabitResponse struct {
@@ -313,10 +313,11 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
UserID: userID, UserID: userID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
CarryOver: req.CarryOver, Frequency: req.Frequency,
TargetValue: req.TargetValue,
SpecificDays: req.SpecificDays, SpecificDays: req.SpecificDays,
SpecificDates: req.SpecificDates, SpecificDates: req.SpecificDates,
CarryOver: req.CarryOver,
TargetValue: req.TargetValue,
} }
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil { if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
@@ -89,6 +89,7 @@ func TestHabitCRUDFlow(t *testing.T) {
reqBody := UpdateHabitRequest{ reqBody := UpdateHabitRequest{
Name: "Morning Exercise", Name: "Morning Exercise",
Description: "Updated description", Description: "Updated description",
Frequency: "DAILY",
} }
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token) rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token)
@@ -11,6 +11,7 @@ import (
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/i18n" "apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/crypto" "apocapoc-api/internal/infrastructure/crypto"
@@ -43,14 +44,15 @@ func setupTestServer(t *testing.T) *TestServer {
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(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) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo) revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false) verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, noOpEmail, false)
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "") resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, noOpEmail, "")
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "") requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, noOpEmail, "")
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher) resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
createHandler := commands.NewCreateHabitHandler(habitRepo) createHandler := commands.NewCreateHabitHandler(habitRepo)
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
+3
View File
@@ -30,6 +30,9 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
AllowCredentials: true, 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) { r.Get("/api/v1/docs", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/docs/index.html", http.StatusMovedPermanently) http.Redirect(w, r, "/api/v1/docs/index.html", http.StatusMovedPermanently)
}) })
@@ -27,7 +27,7 @@ func NewStatsHandlers(
// GetHabitStats godoc // GetHabitStats godoc
// @Summary Get habit statistics // @Summary Get habit statistics
// @Description Get statistics for a specific habit including streaks and completion rates // @Description Get statistics for a specific habit including streaks and completions
// @Tags stats // @Tags stats
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -120,7 +120,8 @@ func TestHabitUpdateAffectsStats(t *testing.T) {
t.Run("Stats remain after updating habit name", func(t *testing.T) { t.Run("Stats remain after updating habit name", func(t *testing.T) {
updateReq := UpdateHabitRequest{ updateReq := UpdateHabitRequest{
Name: "Morning Running", Name: "Morning Running",
Frequency: "DAILY",
} }
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token) rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {