6 Commits

Author SHA1 Message Date
david ed50d2427e fix: return field-level validation errors on POST /habits
Previously all validation failures in CreateHabitHandler returned a
generic {"error":"invalid input"}, making it impossible to tell
whether type, frequency, specific_days or specific_dates was the
problem. Errors are now wrapped with field + i18n key and the HTTP
layer replies via respondValidationErrorI18n, matching the pattern
already used by auth endpoints.
2026-04-17 19:04:23 +02:00
david c20720f120 Add live docs URL to README and remove broken /docs shortcut 2026-03-27 01:09:14 +01:00
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
22 changed files with 259 additions and 67 deletions
+4 -2
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)
@@ -139,7 +139,9 @@ API runs on `http://localhost:8080`
## API Documentation ## API Documentation
Access the interactive Swagger UI at `http://localhost:8080/api/v1/docs` **Live:** [apocapoc.app/api/v1/docs](https://apocapoc.app/api/v1/docs)
**Local:** `http://localhost:8080/api/v1/docs`
Includes endpoint reference, schemas, authentication examples, and live testing. Includes endpoint reference, schemas, authentication examples, and live testing.
+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
@@ -2,6 +2,7 @@ package commands
import ( import (
"context" "context"
"fmt"
"apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/domain/repositories"
@@ -32,19 +33,19 @@ func NewCreateHabitHandler(habitRepo repositories.HabitRepository) *CreateHabitH
func (h *CreateHabitHandler) Handle(ctx context.Context, cmd CreateHabitCommand) (string, error) { func (h *CreateHabitHandler) Handle(ctx context.Context, cmd CreateHabitCommand) (string, error) {
if !cmd.Type.IsValid() { if !cmd.Type.IsValid() {
return "", errors.ErrInvalidInput return "", fmt.Errorf("%w: type: type_invalid", errors.ErrInvalidInput)
} }
if !cmd.Frequency.IsValid() { if !cmd.Frequency.IsValid() {
return "", errors.ErrInvalidInput return "", fmt.Errorf("%w: frequency: frequency_invalid", errors.ErrInvalidInput)
} }
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 { if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
return "", errors.ErrInvalidInput return "", fmt.Errorf("%w: specific_days: specific_days_required", errors.ErrInvalidInput)
} }
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 { if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
return "", errors.ErrInvalidInput return "", fmt.Errorf("%w: specific_dates: specific_dates_required", errors.ErrInvalidInput)
} }
habit := entities.NewHabit(cmd.UserID, cmd.Name, cmd.Type, cmd.Frequency, cmd.CarryOver, cmd.IsNegative) habit := entities.NewHabit(cmd.UserID, cmd.Name, cmd.Type, cmd.Frequency, cmd.CarryOver, cmd.IsNegative)
@@ -4,6 +4,8 @@ import (
"apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
stderrors "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -110,8 +112,31 @@ func TestCreateHabitHandler_InvalidType(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput { if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput, got %v", err) t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "type: type_invalid") {
t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error())
}
}
func TestCreateHabitHandler_EmptyType(t *testing.T) {
mock := &mockHabitRepo{}
handler := NewCreateHabitHandler(mock)
cmd := CreateHabitCommand{
UserID: "user-123",
Name: "Exercise",
Frequency: "DAILY",
}
_, err := handler.Handle(context.Background(), cmd)
if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "type: type_invalid") {
t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error())
} }
} }
@@ -128,8 +153,11 @@ func TestCreateHabitHandler_InvalidFrequency(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput { if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput, got %v", err) t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "frequency: frequency_invalid") {
t.Errorf("Expected 'frequency: frequency_invalid' in error, got %q", err.Error())
} }
} }
@@ -147,8 +175,11 @@ func TestCreateHabitHandler_WeeklyWithoutSpecificDays(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput { if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput, got %v", err) t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "specific_days: specific_days_required") {
t.Errorf("Expected 'specific_days: specific_days_required' in error, got %q", err.Error())
} }
} }
@@ -166,8 +197,11 @@ func TestCreateHabitHandler_MonthlyWithoutSpecificDates(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput { if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput, got %v", err) t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "specific_dates: specific_dates_required") {
t.Errorf("Expected 'specific_dates: specific_dates_required' in error, got %q", err.Error())
} }
} }
@@ -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) {
@@ -91,6 +96,7 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
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)
@@ -114,6 +120,7 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
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)
@@ -138,6 +145,7 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) {
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)
@@ -161,6 +169,7 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
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{
+1
View File
@@ -22,6 +22,7 @@ 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"`
Frequency value_objects.Frequency `json:"frequency"`
SpecificDays []int `json:"specific_days,omitempty"` SpecificDays []int `json:"specific_days,omitempty"`
SpecificDates []int `json:"specific_dates,omitempty"` SpecificDates []int `json:"specific_dates,omitempty"`
CarryOver bool `json:"carry_over"` CarryOver bool `json:"carry_over"`
@@ -2,6 +2,7 @@ package http
import ( import (
"encoding/json" "encoding/json"
stderrors "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -97,8 +98,8 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
habitID, err := h.createHandler.Handle(r.Context(), cmd) habitID, err := h.createHandler.Handle(r.Context(), cmd)
if err != nil { if err != nil {
if err == errors.ErrInvalidInput { if stderrors.Is(err, errors.ErrInvalidInput) {
respondError(w, http.StatusBadRequest, err.Error()) respondValidationErrorI18n(w, r, h.translator, err)
return return
} }
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit") respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit")
@@ -313,10 +314,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)
@@ -122,6 +123,47 @@ func TestHabitCRUDFlow(t *testing.T) {
} }
}) })
t.Run("Create habit with missing type returns field-level error", func(t *testing.T) {
token := registerAndLogin(t, *ts.Router, "validationuser@example.com", "Password123!")
reqBody := map[string]any{"name": "No Type"}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token)
if rr.Code != http.StatusBadRequest {
t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String())
}
var resp ValidationErrorResponse
decodeResponse(t, rr, &resp)
if resp.Field != "type" {
t.Errorf("Expected field 'type', got %q. Body: %s", resp.Field, rr.Body.String())
}
if resp.Error == "" {
t.Errorf("Expected non-empty translated error message. Body: %s", rr.Body.String())
}
})
t.Run("Create weekly habit without specific_days returns field-level error", func(t *testing.T) {
token := registerAndLogin(t, *ts.Router, "weeklyuser@example.com", "Password123!")
reqBody := CreateHabitRequest{
Name: "Cut nails",
Type: "BOOLEAN",
Frequency: "WEEKLY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token)
if rr.Code != http.StatusBadRequest {
t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String())
}
var resp ValidationErrorResponse
decodeResponse(t, rr, &resp)
if resp.Field != "specific_days" {
t.Errorf("Expected field 'specific_days', got %q. Body: %s", resp.Field, rr.Body.String())
}
})
t.Run("Access other user's habit", func(t *testing.T) { t.Run("Access other user's habit", func(t *testing.T) {
otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!") otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
@@ -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)
@@ -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
@@ -121,6 +121,7 @@ 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 {