package predicates_test import ( "testing" "time" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/task/predicates" ) // Helper to create a time pointer func timePtr(t time.Time) *time.Time { return &t } func TestIsCompleted(t *testing.T) { tests := []struct { name string task *models.Task expected bool }{ { name: "completed: NextDueDate nil with completions", task: &models.Task{ NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, expected: true, }, { name: "not completed: NextDueDate set with completions", task: &models.Task{ NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)), Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, expected: false, }, { name: "not completed: NextDueDate nil without completions", task: &models.Task{ NextDueDate: nil, Completions: []models.TaskCompletion{}, }, expected: false, }, { name: "not completed: NextDueDate set without completions", task: &models.Task{ NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)), Completions: []models.TaskCompletion{}, }, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsCompleted(tt.task) if result != tt.expected { t.Errorf("IsCompleted() = %v, expected %v", result, tt.expected) } }) } } func TestIsActive(t *testing.T) { tests := []struct { name string task *models.Task expected bool }{ { name: "active: not cancelled, not archived", task: &models.Task{IsCancelled: false, IsArchived: false}, expected: true, }, { name: "not active: cancelled", task: &models.Task{IsCancelled: true, IsArchived: false}, expected: false, }, { name: "not active: archived", task: &models.Task{IsCancelled: false, IsArchived: true}, expected: false, }, { name: "not active: both cancelled and archived", task: &models.Task{IsCancelled: true, IsArchived: true}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsActive(tt.task) if result != tt.expected { t.Errorf("IsActive() = %v, expected %v", result, tt.expected) } }) } } func TestIsInProgress(t *testing.T) { tests := []struct { name string task *models.Task expected bool }{ { name: "in progress: InProgress is true", task: &models.Task{InProgress: true}, expected: true, }, { name: "not in progress: InProgress is false", task: &models.Task{InProgress: false}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsInProgress(tt.task) if result != tt.expected { t.Errorf("IsInProgress() = %v, expected %v", result, tt.expected) } }) } } func TestEffectiveDate(t *testing.T) { now := time.Now() nextWeek := now.AddDate(0, 0, 7) nextMonth := now.AddDate(0, 1, 0) tests := []struct { name string task *models.Task expected *time.Time }{ { name: "prefers NextDueDate when both set", task: &models.Task{ NextDueDate: timePtr(nextWeek), DueDate: timePtr(nextMonth), }, expected: timePtr(nextWeek), }, { name: "falls back to DueDate when NextDueDate nil", task: &models.Task{ NextDueDate: nil, DueDate: timePtr(nextMonth), }, expected: timePtr(nextMonth), }, { name: "returns nil when both nil", task: &models.Task{ NextDueDate: nil, DueDate: nil, }, expected: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.EffectiveDate(tt.task) if tt.expected == nil { if result != nil { t.Errorf("EffectiveDate() = %v, expected nil", result) } } else { if result == nil { t.Errorf("EffectiveDate() = nil, expected %v", tt.expected) } else if !result.Equal(*tt.expected) { t.Errorf("EffectiveDate() = %v, expected %v", result, tt.expected) } } }) } } func TestIsOverdue(t *testing.T) { now := time.Now().UTC() yesterday := now.AddDate(0, 0, -1) tomorrow := now.AddDate(0, 0, 1) // Start of today - this is what a DATE column stores (midnight) startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) tests := []struct { name string task *models.Task now time.Time expected bool }{ { name: "overdue: effective date in past", task: &models.Task{ NextDueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, expected: true, }, { name: "not overdue: effective date in future", task: &models.Task{ NextDueDate: timePtr(tomorrow), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, expected: false, }, { name: "not overdue: task due today (start of day)", task: &models.Task{ NextDueDate: timePtr(startOfToday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, // Current time during the day expected: false, }, { name: "not overdue: cancelled task", task: &models.Task{ NextDueDate: timePtr(yesterday), IsCancelled: true, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, expected: false, }, { name: "not overdue: archived task", task: &models.Task{ NextDueDate: timePtr(yesterday), IsCancelled: false, IsArchived: true, Completions: []models.TaskCompletion{}, }, now: now, expected: false, }, { name: "not overdue: completed task (NextDueDate nil with completions)", task: &models.Task{ NextDueDate: nil, DueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, now: now, expected: false, }, { name: "not overdue: no due date", task: &models.Task{ NextDueDate: nil, DueDate: nil, IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, expected: false, }, { name: "overdue: uses DueDate when NextDueDate nil (no completions)", task: &models.Task{ NextDueDate: nil, DueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsOverdue(tt.task, tt.now) if result != tt.expected { t.Errorf("IsOverdue() = %v, expected %v", result, tt.expected) } }) } } func TestIsDueSoon(t *testing.T) { now := time.Now().UTC() yesterday := now.AddDate(0, 0, -1) in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) // Start of today - this is what a DATE column stores (midnight) startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) tests := []struct { name string task *models.Task now time.Time daysThreshold int expected bool }{ { name: "due soon: within threshold", task: &models.Task{ NextDueDate: timePtr(in5Days), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: true, }, { name: "due soon: task due today (start of day)", task: &models.Task{ NextDueDate: timePtr(startOfToday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, // Current time during the day daysThreshold: 30, expected: true, }, { name: "not due soon: beyond threshold", task: &models.Task{ NextDueDate: timePtr(in60Days), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, { name: "not due soon: overdue (in past)", task: &models.Task{ NextDueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, { name: "not due soon: cancelled", task: &models.Task{ NextDueDate: timePtr(in5Days), IsCancelled: true, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, { name: "not due soon: completed", task: &models.Task{ NextDueDate: nil, DueDate: timePtr(in5Days), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, now: now, daysThreshold: 30, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold) if result != tt.expected { t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected) } }) } } func TestIsUpcoming(t *testing.T) { now := time.Now().UTC() in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) tests := []struct { name string task *models.Task now time.Time daysThreshold int expected bool }{ { name: "upcoming: beyond threshold", task: &models.Task{ NextDueDate: timePtr(in60Days), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: true, }, { name: "upcoming: no due date", task: &models.Task{ NextDueDate: nil, DueDate: nil, IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: true, }, { name: "not upcoming: within due soon threshold", task: &models.Task{ NextDueDate: timePtr(in5Days), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, { name: "not upcoming: cancelled", task: &models.Task{ NextDueDate: timePtr(in60Days), IsCancelled: true, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold) if result != tt.expected { t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected) } }) } } func TestHasCompletions(t *testing.T) { tests := []struct { name string task *models.Task expected bool }{ { name: "has completions", task: &models.Task{Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, expected: true, }, { name: "no completions", task: &models.Task{Completions: []models.TaskCompletion{}}, expected: false, }, { name: "nil completions", task: &models.Task{Completions: nil}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.HasCompletions(tt.task) if result != tt.expected { t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected) } }) } } func TestIsRecurring(t *testing.T) { days7 := 7 days0 := 0 days14 := 14 tests := []struct { name string task *models.Task expected bool }{ { name: "recurring: frequency with days > 0", task: &models.Task{Frequency: &models.TaskFrequency{Days: &days7}}, expected: true, }, { name: "not recurring: frequency without days (one-time)", task: &models.Task{Frequency: &models.TaskFrequency{Days: nil}}, expected: false, }, { name: "not recurring: no frequency", task: &models.Task{Frequency: nil}, expected: false, }, { name: "not recurring: frequency with days = 0 (Once)", task: &models.Task{Frequency: &models.TaskFrequency{Days: &days0}}, expected: false, }, { name: "recurring: FrequencyID set with CustomIntervalDays", task: &models.Task{ FrequencyID: uintPtr(5), Frequency: &models.TaskFrequency{Days: &days14}, CustomIntervalDays: intPtr(14), }, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsRecurring(tt.task) if result != tt.expected { t.Errorf("IsRecurring() = %v, expected %v", result, tt.expected) } }) } } func TestIsOneTime(t *testing.T) { days7 := 7 tests := []struct { name string task *models.Task expected bool }{ { name: "one-time: no frequency", task: &models.Task{Frequency: nil}, expected: true, }, { name: "one-time: Once frequency (days nil)", task: &models.Task{Frequency: &models.TaskFrequency{Name: "Once", Days: nil}}, expected: true, }, { name: "not one-time: recurring frequency", task: &models.Task{Frequency: &models.TaskFrequency{Name: "Weekly", Days: &days7}}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsOneTime(tt.task) if result != tt.expected { t.Errorf("IsOneTime() = %v, expected %v", result, tt.expected) } }) } } func TestIsDueSoon_AdditionalCases(t *testing.T) { now := time.Now().UTC() startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) tests := []struct { name string task *models.Task now time.Time daysThreshold int expected bool }{ { name: "not due soon: no due date", task: &models.Task{ NextDueDate: nil, DueDate: nil, IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, { name: "due soon: task due today (start of day)", task: &models.Task{ NextDueDate: timePtr(startOfToday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold) if result != tt.expected { t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected) } }) } } func TestHasCompletions_CompletionCount(t *testing.T) { tests := []struct { name string task *models.Task expected bool }{ { name: "has completions via CompletionCount", task: &models.Task{Completions: nil, CompletionCount: 3}, expected: true, }, { name: "no completions: CompletionCount zero, no preloaded completions", task: &models.Task{Completions: nil, CompletionCount: 0}, expected: false, }, { name: "has completions via preloaded slice (ignores CompletionCount)", task: &models.Task{ Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, CompletionCount: 0, }, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.HasCompletions(tt.task) if result != tt.expected { t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected) } }) } } func TestGetCompletionCount(t *testing.T) { tests := []struct { name string task *models.Task expected int }{ { name: "zero completions: empty slice", task: &models.Task{Completions: []models.TaskCompletion{}, CompletionCount: 0}, expected: 0, }, { name: "three completions via preloaded slice", task: &models.Task{ Completions: []models.TaskCompletion{ {BaseModel: models.BaseModel{ID: 1}}, {BaseModel: models.BaseModel{ID: 2}}, {BaseModel: models.BaseModel{ID: 3}}, }, }, expected: 3, }, { name: "count via CompletionCount when slice is empty", task: &models.Task{Completions: nil, CompletionCount: 5}, expected: 5, }, { name: "nil completions and zero CompletionCount", task: &models.Task{Completions: nil, CompletionCount: 0}, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.GetCompletionCount(tt.task) if result != tt.expected { t.Errorf("GetCompletionCount() = %v, expected %v", result, tt.expected) } }) } } func TestIsUpcoming_AdditionalCases(t *testing.T) { now := time.Now().UTC() yesterday := now.AddDate(0, 0, -1) tests := []struct { name string task *models.Task now time.Time daysThreshold int expected bool }{ { name: "not upcoming: overdue task", task: &models.Task{ NextDueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{}, }, now: now, daysThreshold: 30, expected: false, }, { name: "not upcoming: completed task", task: &models.Task{ NextDueDate: nil, DueDate: nil, IsCancelled: false, IsArchived: false, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, now: now, daysThreshold: 30, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold) if result != tt.expected { t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected) } }) } } // Helper to create a uint pointer func uintPtr(v uint) *uint { return &v } // Helper to create an int pointer func intPtr(v int) *int { return &v }