From 94c5c30d09ab7942e2a90ccb3a88196195e715c5 Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Sun, 8 Mar 2026 00:02:37 +0100 Subject: [PATCH] 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 --- README.md | 2 +- docs/docs.go | 5 +- docs/swagger.json | 5 +- docs/swagger.yaml | 5 +- internal/application/commands/update_habit.go | 23 ++++- .../application/commands/update_habit_test.go | 97 +++++++++++++++---- internal/infrastructure/http/dto.go | 13 +-- .../infrastructure/http/habit_handlers.go | 5 +- .../http/habit_integration_test.go | 1 + .../infrastructure/http/stats_handlers.go | 2 +- .../http/stats_integration_test.go | 3 +- 11 files changed, 125 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 27fc4d3..44da404 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ REST API for habit tracking built with Go. Self-hosted alternative for developer - Multiple habit types: Boolean, Counter, Value - Flexible scheduling: Daily, Weekly, Monthly -- Statistics: Streaks, completion rates, progress tracking +- Statistics: Streaks and completions tracking - JWT authentication, rate limiting, optional email verification - Registration modes: Open or closed - SQLite database (single file) diff --git a/docs/docs.go b/docs/docs.go index 2cdb84d..1e8f556 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1172,7 +1172,7 @@ const docTemplate = `{ "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": [ "application/json" ], @@ -1800,6 +1800,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "frequency": { + "$ref": "#/definitions/value_objects.Frequency" + }, "name": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 5cbd04c..08e9a4f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1164,7 +1164,7 @@ "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": [ "application/json" ], @@ -1792,6 +1792,9 @@ "description": { "type": "string" }, + "frequency": { + "$ref": "#/definitions/value_objects.Frequency" + }, "name": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 44f162f..87aa66e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -268,6 +268,8 @@ definitions: type: boolean description: type: string + frequency: + $ref: '#/definitions/value_objects.Frequency' name: type: string specific_dates: @@ -1189,8 +1191,7 @@ paths: - system /stats/habits/{id}: get: - description: Get statistics for a specific habit including streaks and completion - rates + description: Get statistics for a specific habit including streaks and completions parameters: - description: Habit ID in: path diff --git a/internal/application/commands/update_habit.go b/internal/application/commands/update_habit.go index 4a11d4c..1195549 100644 --- a/internal/application/commands/update_habit.go +++ b/internal/application/commands/update_habit.go @@ -5,6 +5,7 @@ import ( "strings" "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/domain/value_objects" "apocapoc-api/internal/shared/errors" ) @@ -13,10 +14,11 @@ type UpdateHabitCommand struct { UserID string Name string Description string - CarryOver bool - TargetValue *float64 + Frequency value_objects.Frequency SpecificDays []int SpecificDates []int + CarryOver bool + TargetValue *float64 } type UpdateHabitHandler struct { @@ -34,6 +36,18 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand) 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) if err != nil { return err @@ -49,10 +63,11 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand) habit.Name = cmd.Name habit.Description = cmd.Description - habit.CarryOver = cmd.CarryOver - habit.TargetValue = cmd.TargetValue + habit.Frequency = cmd.Frequency habit.SpecificDays = cmd.SpecificDays habit.SpecificDates = cmd.SpecificDates + habit.CarryOver = cmd.CarryOver + habit.TargetValue = cmd.TargetValue return h.habitRepo.Update(ctx, habit) } diff --git a/internal/application/commands/update_habit_test.go b/internal/application/commands/update_habit_test.go index 2861da2..b0384aa 100644 --- a/internal/application/commands/update_habit_test.go +++ b/internal/application/commands/update_habit_test.go @@ -48,9 +48,10 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) { UserID: "user-123", Name: "Morning Exercise", Description: "Updated description", + Frequency: value_objects.FrequencyWeekly, + SpecificDays: []int{1, 3, 5}, CarryOver: true, TargetValue: &newTargetValue, - SpecificDays: []int{1, 3, 5}, } 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) } + 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 { 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 { 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) { @@ -88,9 +93,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { handler := NewUpdateHabitHandler(habitRepo) cmd := UpdateHabitCommand{ - HabitID: "non-existent", - UserID: "user-123", - Name: "Exercise", + HabitID: "non-existent", + UserID: "user-123", + Name: "Exercise", + Frequency: value_objects.FrequencyDaily, } err := handler.Handle(context.Background(), cmd) @@ -111,9 +117,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) { handler := NewUpdateHabitHandler(habitRepo) cmd := UpdateHabitCommand{ - HabitID: "habit-1", - UserID: "user-456", // Different user - Name: "Exercise", + HabitID: "habit-1", + UserID: "user-456", // Different user + Name: "Exercise", + Frequency: value_objects.FrequencyDaily, } err := handler.Handle(context.Background(), cmd) @@ -135,9 +142,10 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) { handler := NewUpdateHabitHandler(habitRepo) cmd := UpdateHabitCommand{ - HabitID: "habit-1", - UserID: "user-123", - Name: "Updated Exercise", + HabitID: "habit-1", + UserID: "user-123", + Name: "Updated Exercise", + Frequency: value_objects.FrequencyDaily, } err := handler.Handle(context.Background(), cmd) @@ -158,9 +166,10 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) { handler := NewUpdateHabitHandler(habitRepo) cmd := UpdateHabitCommand{ - HabitID: "habit-1", - UserID: "user-123", - Name: "", // Empty name + HabitID: "habit-1", + UserID: "user-123", + Name: "", // Empty name + Frequency: value_objects.FrequencyDaily, } 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) } } + +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) + } +} diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go index c21ecd7..7f7454d 100644 --- a/internal/infrastructure/http/dto.go +++ b/internal/infrastructure/http/dto.go @@ -20,12 +20,13 @@ type CreateHabitRequest struct { } type UpdateHabitRequest struct { - Name string `json:"name"` - Description string `json:"description"` - SpecificDays []int `json:"specific_days,omitempty"` - SpecificDates []int `json:"specific_dates,omitempty"` - CarryOver bool `json:"carry_over"` - TargetValue *float64 `json:"target_value,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Frequency value_objects.Frequency `json:"frequency"` + SpecificDays []int `json:"specific_days,omitempty"` + SpecificDates []int `json:"specific_dates,omitempty"` + CarryOver bool `json:"carry_over"` + TargetValue *float64 `json:"target_value,omitempty"` } type HabitResponse struct { diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 20ec897..fb820ea 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -313,10 +313,11 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) { UserID: userID, Name: req.Name, Description: req.Description, - CarryOver: req.CarryOver, - TargetValue: req.TargetValue, + Frequency: req.Frequency, SpecificDays: req.SpecificDays, SpecificDates: req.SpecificDates, + CarryOver: req.CarryOver, + TargetValue: req.TargetValue, } if err := h.updateHandler.Handle(r.Context(), cmd); err != nil { diff --git a/internal/infrastructure/http/habit_integration_test.go b/internal/infrastructure/http/habit_integration_test.go index 95590c1..c7add21 100644 --- a/internal/infrastructure/http/habit_integration_test.go +++ b/internal/infrastructure/http/habit_integration_test.go @@ -89,6 +89,7 @@ func TestHabitCRUDFlow(t *testing.T) { reqBody := UpdateHabitRequest{ Name: "Morning Exercise", Description: "Updated description", + Frequency: "DAILY", } rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token) diff --git a/internal/infrastructure/http/stats_handlers.go b/internal/infrastructure/http/stats_handlers.go index cd49d9b..5c70440 100644 --- a/internal/infrastructure/http/stats_handlers.go +++ b/internal/infrastructure/http/stats_handlers.go @@ -27,7 +27,7 @@ func NewStatsHandlers( // GetHabitStats godoc // @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 // @Produce json // @Security BearerAuth diff --git a/internal/infrastructure/http/stats_integration_test.go b/internal/infrastructure/http/stats_integration_test.go index 1e64274..2676854 100644 --- a/internal/infrastructure/http/stats_integration_test.go +++ b/internal/infrastructure/http/stats_integration_test.go @@ -120,7 +120,8 @@ func TestHabitUpdateAffectsStats(t *testing.T) { t.Run("Stats remain after updating habit name", func(t *testing.T) { updateReq := UpdateHabitRequest{ - Name: "Morning Running", + Name: "Morning Running", + Frequency: "DAILY", } rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token) if rr.Code != http.StatusOK {