diff --git a/internal/application/commands/create_habit.go b/internal/application/commands/create_habit.go new file mode 100644 index 0000000..ee66a40 --- /dev/null +++ b/internal/application/commands/create_habit.go @@ -0,0 +1,62 @@ +package commands + +import ( + "context" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/domain/repositories" + "habit-tracker-api/internal/domain/value_objects" + "habit-tracker-api/internal/shared/errors" +) + +type CreateHabitCommand struct { + UserID string + Name string + Description string + Type string + Frequency string + SpecificDays []int + SpecificDates []int + CarryOver bool + TargetValue *float64 +} + +type CreateHabitHandler struct { + habitRepo repositories.HabitRepository +} + +func NewCreateHabitHandler(habitRepo repositories.HabitRepository) *CreateHabitHandler { + return &CreateHabitHandler{habitRepo: habitRepo} +} + +func (h *CreateHabitHandler) Handle(ctx context.Context, cmd CreateHabitCommand) (string, error) { + habitType := value_objects.HabitType(cmd.Type) + if !habitType.IsValid() { + return "", errors.ErrInvalidInput + } + + frequency := value_objects.Frequency(cmd.Frequency) + if !frequency.IsValid() { + return "", errors.ErrInvalidInput + } + + if frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 { + return "", errors.ErrInvalidInput + } + + if frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 { + return "", errors.ErrInvalidInput + } + + habit := entities.NewHabit(cmd.UserID, cmd.Name, habitType, frequency, cmd.CarryOver) + habit.Description = cmd.Description + habit.SpecificDays = cmd.SpecificDays + habit.SpecificDates = cmd.SpecificDates + habit.TargetValue = cmd.TargetValue + + if err := h.habitRepo.Create(ctx, habit); err != nil { + return "", err + } + + return habit.ID, nil +} diff --git a/internal/application/commands/create_habit_test.go b/internal/application/commands/create_habit_test.go new file mode 100644 index 0000000..af2c348 --- /dev/null +++ b/internal/application/commands/create_habit_test.go @@ -0,0 +1,197 @@ +package commands + +import ( + "context" + "testing" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/shared/errors" +) + +type mockHabitRepo struct { + createFunc func(ctx context.Context, habit *entities.Habit) error +} + +func (m *mockHabitRepo) Create(ctx context.Context, habit *entities.Habit) error { + return m.createFunc(ctx, habit) +} + +func (m *mockHabitRepo) FindByID(ctx context.Context, id string) (*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepo) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepo) Update(ctx context.Context, habit *entities.Habit) error { + return nil +} + +func (m *mockHabitRepo) Delete(ctx context.Context, id string) error { + return nil +} + +func TestCreateHabitHandler_Success(t *testing.T) { + mock := &mockHabitRepo{ + createFunc: func(ctx context.Context, habit *entities.Habit) error { + habit.ID = "habit-123" + return nil + }, + } + + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Description: "Daily workout", + Type: "BOOLEAN", + Frequency: "DAILY", + CarryOver: true, + } + + habitID, err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if habitID == "" { + t.Error("Expected habit ID to be returned") + } +} + +func TestCreateHabitHandler_InvalidType(t *testing.T) { + mock := &mockHabitRepo{} + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Type: "INVALID", + Frequency: "DAILY", + } + + _, err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrInvalidInput { + t.Errorf("Expected ErrInvalidInput, got %v", err) + } +} + +func TestCreateHabitHandler_InvalidFrequency(t *testing.T) { + mock := &mockHabitRepo{} + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Type: "BOOLEAN", + Frequency: "INVALID", + } + + _, err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrInvalidInput { + t.Errorf("Expected ErrInvalidInput, got %v", err) + } +} + +func TestCreateHabitHandler_WeeklyWithoutSpecificDays(t *testing.T) { + mock := &mockHabitRepo{} + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Type: "BOOLEAN", + Frequency: "WEEKLY", + SpecificDays: []int{}, + } + + _, err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrInvalidInput { + t.Errorf("Expected ErrInvalidInput, got %v", err) + } +} + +func TestCreateHabitHandler_MonthlyWithoutSpecificDates(t *testing.T) { + mock := &mockHabitRepo{} + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Type: "BOOLEAN", + Frequency: "MONTHLY", + SpecificDates: []int{}, + } + + _, err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrInvalidInput { + t.Errorf("Expected ErrInvalidInput, got %v", err) + } +} + +func TestCreateHabitHandler_WeeklyWithSpecificDays(t *testing.T) { + mock := &mockHabitRepo{ + createFunc: func(ctx context.Context, habit *entities.Habit) error { + habit.ID = "habit-123" + if len(habit.SpecificDays) != 3 { + t.Errorf("Expected 3 specific days, got %d", len(habit.SpecificDays)) + } + return nil + }, + } + + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Type: "BOOLEAN", + Frequency: "WEEKLY", + SpecificDays: []int{1, 3, 5}, + } + + _, err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestCreateHabitHandler_MonthlyWithSpecificDates(t *testing.T) { + mock := &mockHabitRepo{ + createFunc: func(ctx context.Context, habit *entities.Habit) error { + habit.ID = "habit-123" + if len(habit.SpecificDates) != 2 { + t.Errorf("Expected 2 specific dates, got %d", len(habit.SpecificDates)) + } + return nil + }, + } + + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Pay bills", + Type: "BOOLEAN", + Frequency: "MONTHLY", + SpecificDates: []int{1, 15}, + } + + _, err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} diff --git a/internal/application/commands/mark_habit.go b/internal/application/commands/mark_habit.go new file mode 100644 index 0000000..97f050b --- /dev/null +++ b/internal/application/commands/mark_habit.go @@ -0,0 +1,50 @@ +package commands + +import ( + "context" + "fmt" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/domain/repositories" +) + +type MarkHabitCommand struct { + HabitID string + ScheduledDate time.Time + Value *float64 +} + +type MarkHabitHandler struct { + entryRepo repositories.HabitEntryRepository + habitRepo repositories.HabitRepository +} + +func NewMarkHabitHandler( + entryRepo repositories.HabitEntryRepository, + habitRepo repositories.HabitRepository, +) *MarkHabitHandler { + return &MarkHabitHandler{ + entryRepo: entryRepo, + habitRepo: habitRepo, + } +} + +func (h *MarkHabitHandler) Handle(ctx context.Context, cmd MarkHabitCommand) error { + habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID) + if err != nil { + return err + } + + if habit == nil { + return fmt.Errorf("habit not found") + } + + if !habit.IsActive() { + return fmt.Errorf("habit is archived") + } + + entry := entities.NewHabitEntry(cmd.HabitID, cmd.ScheduledDate, cmd.Value) + + return h.entryRepo.Create(ctx, entry) +} diff --git a/internal/application/commands/mark_habit_test.go b/internal/application/commands/mark_habit_test.go new file mode 100644 index 0000000..7490b3e --- /dev/null +++ b/internal/application/commands/mark_habit_test.go @@ -0,0 +1,204 @@ +package commands + +import ( + "context" + "testing" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/domain/value_objects" + "habit-tracker-api/internal/shared/errors" +) + +type mockEntryRepo struct { + createFunc func(ctx context.Context, entry *entities.HabitEntry) error +} + +func (m *mockEntryRepo) Create(ctx context.Context, entry *entities.HabitEntry) error { + if m.createFunc != nil { + return m.createFunc(ctx, entry) + } + return nil +} + +func (m *mockEntryRepo) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) Update(ctx context.Context, entry *entities.HabitEntry) error { + return nil +} + +func (m *mockEntryRepo) Delete(ctx context.Context, id string) error { + return nil +} + +type mockHabitRepoForMark struct { + habit *entities.Habit +} + +func (m *mockHabitRepoForMark) Create(ctx context.Context, habit *entities.Habit) error { + return nil +} + +func (m *mockHabitRepoForMark) FindByID(ctx context.Context, id string) (*entities.Habit, error) { + return m.habit, nil +} + +func (m *mockHabitRepoForMark) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepoForMark) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepoForMark) Update(ctx context.Context, habit *entities.Habit) error { + return nil +} + +func (m *mockHabitRepoForMark) Delete(ctx context.Context, id string) error { + return nil +} + +func TestMarkHabitHandler_Success(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForMark{habit: habit} + entryRepo := &mockEntryRepo{ + createFunc: func(ctx context.Context, entry *entities.HabitEntry) error { + if entry.HabitID != "habit-1" { + t.Errorf("Expected HabitID habit-1, got %s", entry.HabitID) + } + entry.ID = "entry-123" + return nil + }, + } + + handler := NewMarkHabitHandler(entryRepo, habitRepo) + + cmd := MarkHabitCommand{ + HabitID: "habit-1", + ScheduledDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + Value: nil, + } + + err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestMarkHabitHandler_HabitNotFound(t *testing.T) { + habitRepo := &mockHabitRepoForMark{habit: nil} + entryRepo := &mockEntryRepo{} + + handler := NewMarkHabitHandler(entryRepo, habitRepo) + + cmd := MarkHabitCommand{ + HabitID: "non-existent", + ScheduledDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + } + + err := handler.Handle(context.Background(), cmd) + + if err == nil { + t.Error("Expected error when habit not found") + } +} + +func TestMarkHabitHandler_ArchivedHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + habit.Archive() + + habitRepo := &mockHabitRepoForMark{habit: habit} + entryRepo := &mockEntryRepo{} + + handler := NewMarkHabitHandler(entryRepo, habitRepo) + + cmd := MarkHabitCommand{ + HabitID: "habit-1", + ScheduledDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + } + + err := handler.Handle(context.Background(), cmd) + + if err == nil { + t.Error("Expected error when habit is archived") + } +} + +func TestMarkHabitHandler_WithValue(t *testing.T) { + habit := entities.NewHabit("user-123", "Steps", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForMark{habit: habit} + + value := 5000.0 + entryRepo := &mockEntryRepo{ + createFunc: func(ctx context.Context, entry *entities.HabitEntry) error { + if entry.Value == nil { + t.Error("Expected value to be set") + } + if *entry.Value != value { + t.Errorf("Expected value %f, got %f", value, *entry.Value) + } + entry.ID = "entry-123" + return nil + }, + } + + handler := NewMarkHabitHandler(entryRepo, habitRepo) + + cmd := MarkHabitCommand{ + HabitID: "habit-1", + ScheduledDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + Value: &value, + } + + err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestMarkHabitHandler_DuplicateEntry(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForMark{habit: habit} + entryRepo := &mockEntryRepo{ + createFunc: func(ctx context.Context, entry *entities.HabitEntry) error { + return errors.ErrAlreadyExists + }, + } + + handler := NewMarkHabitHandler(entryRepo, habitRepo) + + cmd := MarkHabitCommand{ + HabitID: "habit-1", + ScheduledDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrAlreadyExists { + t.Errorf("Expected ErrAlreadyExists, got %v", err) + } +} diff --git a/internal/application/queries/get_todays_habits.go b/internal/application/queries/get_todays_habits.go new file mode 100644 index 0000000..8e287fd --- /dev/null +++ b/internal/application/queries/get_todays_habits.go @@ -0,0 +1,92 @@ +package queries + +import ( + "context" + "time" + + "habit-tracker-api/internal/domain/repositories" + "habit-tracker-api/internal/shared/utils" +) + +type TodaysHabitDTO struct { + ID string + Name string + Type string + TargetValue *float64 + ScheduledDate time.Time + IsCarriedOver bool +} + +type GetTodaysHabitsQuery struct { + UserID string + Timezone string + Date time.Time +} + +type GetTodaysHabitsHandler struct { + habitRepo repositories.HabitRepository + entryRepo repositories.HabitEntryRepository +} + +func NewGetTodaysHabitsHandler( + habitRepo repositories.HabitRepository, + entryRepo repositories.HabitEntryRepository, +) *GetTodaysHabitsHandler { + return &GetTodaysHabitsHandler{ + habitRepo: habitRepo, + entryRepo: entryRepo, + } +} + +func (h *GetTodaysHabitsHandler) Handle( + ctx context.Context, + query GetTodaysHabitsQuery, +) ([]TodaysHabitDTO, error) { + habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID) + if err != nil { + return nil, err + } + + var result []TodaysHabitDTO + + for _, habit := range habits { + shouldAppear := utils.ShouldAppearToday( + string(habit.Frequency), + habit.SpecificDays, + habit.SpecificDates, + query.Date, + ) + + if !shouldAppear && !habit.CarryOver { + continue + } + + entries, _ := h.entryRepo.FindByHabitIDAndDateRange( + ctx, + habit.ID, + query.Date.AddDate(0, 0, -30), + query.Date, + ) + + isCompleted := false + for _, entry := range entries { + if entry.ScheduledDate.Equal(query.Date) && entry.DeletedAt == nil { + isCompleted = true + break + } + } + + if !isCompleted { + result = append(result, TodaysHabitDTO{ + ID: habit.ID, + Name: habit.Name, + Type: string(habit.Type), + TargetValue: habit.TargetValue, + ScheduledDate: query.Date, + IsCarriedOver: !shouldAppear && habit.CarryOver, + }) + } + } + + return result, nil +} diff --git a/internal/application/queries/get_todays_habits_test.go b/internal/application/queries/get_todays_habits_test.go new file mode 100644 index 0000000..0c00b08 --- /dev/null +++ b/internal/application/queries/get_todays_habits_test.go @@ -0,0 +1,259 @@ +package queries + +import ( + "context" + "testing" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/domain/value_objects" +) + +type mockHabitRepo struct { + habits []*entities.Habit +} + +func (m *mockHabitRepo) Create(ctx context.Context, habit *entities.Habit) error { + return nil +} + +func (m *mockHabitRepo) FindByID(ctx context.Context, id string) (*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + return nil, nil +} + +func (m *mockHabitRepo) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + return m.habits, nil +} + +func (m *mockHabitRepo) Update(ctx context.Context, habit *entities.Habit) error { + return nil +} + +func (m *mockHabitRepo) Delete(ctx context.Context, id string) error { + return nil +} + +type mockEntryRepo struct { + entries []*entities.HabitEntry +} + +func (m *mockEntryRepo) Create(ctx context.Context, entry *entities.HabitEntry) error { + return nil +} + +func (m *mockEntryRepo) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) { + var result []*entities.HabitEntry + for _, e := range m.entries { + if e.HabitID == habitID { + result = append(result, e) + } + } + return result, nil +} + +func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) { + return nil, nil +} + +func (m *mockEntryRepo) Update(ctx context.Context, entry *entities.HabitEntry) error { + return nil +} + +func (m *mockEntryRepo) Delete(ctx context.Context, id string) error { + return nil +} + +func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 habit, got %d", len(results)) + } + + if results[0].ID != "habit-1" { + t.Errorf("Expected habit ID habit-1, got %s", results[0].ID) + } + + if results[0].IsCarriedOver { + t.Error("Expected IsCarriedOver to be false") + } +} + +func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + entry := entities.NewHabitEntry("habit-1", targetDate, nil) + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: targetDate, + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 0 { + t.Fatalf("Expected 0 habits (already completed), got %d", len(results)) + } +} + +func TestGetTodaysHabitsHandler_WeeklyHabitOnCorrectDay(t *testing.T) { + habit := entities.NewHabit("user-123", "Gym", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false) + habit.ID = "habit-1" + habit.SpecificDays = []int{1, 3, 5} + + monday := time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC) + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: monday, + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 habit, got %d", len(results)) + } +} + +func TestGetTodaysHabitsHandler_WeeklyHabitOnWrongDay(t *testing.T) { + habit := entities.NewHabit("user-123", "Gym", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false) + habit.ID = "habit-1" + habit.SpecificDays = []int{1, 3, 5} + + tuesday := time.Date(2025, 1, 7, 0, 0, 0, 0, time.UTC) + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: tuesday, + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 0 { + t.Fatalf("Expected 0 habits (wrong day), got %d", len(results)) + } +} + +func TestGetTodaysHabitsHandler_CarryOverEnabled(t *testing.T) { + habit := entities.NewHabit("user-123", "Gym", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, true) + habit.ID = "habit-1" + habit.SpecificDays = []int{1} + + tuesday := time.Date(2025, 1, 7, 0, 0, 0, 0, time.UTC) + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: tuesday, + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 habit (carry-over), got %d", len(results)) + } + + if !results[0].IsCarriedOver { + t.Error("Expected IsCarriedOver to be true") + } +} + +func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) { + habit := entities.NewHabit("user-123", "Gym", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false) + habit.ID = "habit-1" + habit.SpecificDays = []int{1} + + tuesday := time.Date(2025, 1, 7, 0, 0, 0, 0, time.UTC) + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: tuesday, + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 0 { + t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results)) + } +} diff --git a/internal/shared/utils/date_utils.go b/internal/shared/utils/date_utils.go new file mode 100644 index 0000000..cd90210 --- /dev/null +++ b/internal/shared/utils/date_utils.go @@ -0,0 +1,31 @@ +package utils + +import "time" + +func ShouldAppearToday( + frequency string, + specificDays []int, + specificDates []int, + targetDate time.Time, +) bool { + switch frequency { + case "DAILY": + return true + case "WEEKLY": + weekday := int(targetDate.Weekday()) + return contains(specificDays, weekday) + case "MONTHLY": + day := targetDate.Day() + return contains(specificDates, day) + } + return false +} + +func contains(slice []int, val int) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false +} diff --git a/internal/shared/utils/date_utils_test.go b/internal/shared/utils/date_utils_test.go new file mode 100644 index 0000000..b07d5d6 --- /dev/null +++ b/internal/shared/utils/date_utils_test.go @@ -0,0 +1,107 @@ +package utils + +import ( + "testing" + "time" +) + +func TestShouldAppearToday_Daily(t *testing.T) { + // Daily habits should always appear + result := ShouldAppearToday("DAILY", nil, nil, time.Now()) + if !result { + t.Error("Daily habit should appear every day") + } +} + +func TestShouldAppearToday_Weekly(t *testing.T) { + tests := []struct { + name string + specificDays []int + targetDate time.Time + expected bool + }{ + { + name: "Monday when Monday is specified", + specificDays: []int{1}, // Monday + targetDate: time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC), // Monday + expected: true, + }, + { + name: "Tuesday when Monday is specified", + specificDays: []int{1}, // Monday + targetDate: time.Date(2025, 1, 7, 0, 0, 0, 0, time.UTC), // Tuesday + expected: false, + }, + { + name: "Wednesday when Mon/Wed/Fri specified", + specificDays: []int{1, 3, 5}, // Mon, Wed, Fri + targetDate: time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC), // Wednesday + expected: true, + }, + { + name: "Sunday when Mon/Wed/Fri specified", + specificDays: []int{1, 3, 5}, // Mon, Wed, Fri + targetDate: time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), // Sunday + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldAppearToday("WEEKLY", tt.specificDays, nil, tt.targetDate) + if result != tt.expected { + t.Errorf("Expected %v, got %v for %s", tt.expected, result, tt.targetDate.Weekday()) + } + }) + } +} + +func TestShouldAppearToday_Monthly(t *testing.T) { + tests := []struct { + name string + specificDates []int + targetDate time.Time + expected bool + }{ + { + name: "1st of month when 1st is specified", + specificDates: []int{1}, + targetDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + expected: true, + }, + { + name: "2nd of month when 1st is specified", + specificDates: []int{1}, + targetDate: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + expected: false, + }, + { + name: "15th when 1st/15th/30th specified", + specificDates: []int{1, 15, 30}, + targetDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + expected: true, + }, + { + name: "10th when 1st/15th/30th specified", + specificDates: []int{1, 15, 30}, + targetDate: time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldAppearToday("MONTHLY", nil, tt.specificDates, tt.targetDate) + if result != tt.expected { + t.Errorf("Expected %v, got %v for day %d", tt.expected, result, tt.targetDate.Day()) + } + }) + } +} + +func TestShouldAppearToday_InvalidFrequency(t *testing.T) { + result := ShouldAppearToday("INVALID", nil, nil, time.Now()) + if result { + t.Error("Invalid frequency should return false") + } +}