package categorization import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/treytartt/casera-api/internal/models" ) // Helper to create a time pointer func timePtr(t time.Time) *time.Time { return &t } // Helper to create a uint pointer func uintPtr(v uint) *uint { return &v } // Helper to create a completion with an ID func makeCompletion(id uint) models.TaskCompletion { c := models.TaskCompletion{CompletedAt: time.Now()} c.ID = id return c } // Helper to create a task with an ID func makeTask(id uint) models.Task { t := models.Task{} t.ID = id return t } func TestCancelledHandler(t *testing.T) { chain := NewChain() t.Run("cancelled task goes to cancelled column", func(t *testing.T) { task := &models.Task{ IsCancelled: true, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnCancelled, result) }) t.Run("cancelled task with due date still goes to cancelled", func(t *testing.T) { dueDate := time.Now().AddDate(0, 0, -10) // 10 days ago (overdue) task := &models.Task{ IsCancelled: true, DueDate: &dueDate, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnCancelled, result) }) } func TestCompletedHandler(t *testing.T) { chain := NewChain() t.Run("one-time task with completion and no next_due_date goes to completed", func(t *testing.T) { task := &models.Task{ NextDueDate: nil, Completions: []models.TaskCompletion{makeCompletion(1)}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnCompleted, result) }) t.Run("recurring task with completion but has next_due_date does NOT go to completed", func(t *testing.T) { nextDue := time.Now().AddDate(0, 0, 30) task := &models.Task{ NextDueDate: &nextDue, Completions: []models.TaskCompletion{makeCompletion(1)}, } result := chain.Categorize(task, 30) // Should go to due_soon or upcoming, not completed assert.NotEqual(t, ColumnCompleted, result) }) t.Run("task with no completions does not go to completed", func(t *testing.T) { task := &models.Task{ NextDueDate: nil, Completions: []models.TaskCompletion{}, } result := chain.Categorize(task, 30) assert.NotEqual(t, ColumnCompleted, result) }) } func TestInProgressHandler(t *testing.T) { chain := NewChain() t.Run("task with In Progress status goes to in_progress column", func(t *testing.T) { task := &models.Task{ Status: &models.TaskStatus{Name: "In Progress"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnInProgress, result) }) t.Run("task with Pending status does not go to in_progress", func(t *testing.T) { task := &models.Task{ Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.NotEqual(t, ColumnInProgress, result) }) t.Run("task with nil status does not go to in_progress", func(t *testing.T) { task := &models.Task{ Status: nil, } result := chain.Categorize(task, 30) assert.NotEqual(t, ColumnInProgress, result) }) } func TestOverdueHandler(t *testing.T) { chain := NewChain() t.Run("task with past next_due_date goes to overdue", func(t *testing.T) { pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago task := &models.Task{ NextDueDate: &pastDate, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnOverdue, result) }) t.Run("task with past due_date (no next_due_date) goes to overdue", func(t *testing.T) { pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago task := &models.Task{ DueDate: &pastDate, NextDueDate: nil, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnOverdue, result) }) t.Run("next_due_date takes precedence over due_date", func(t *testing.T) { pastDueDate := time.Now().AddDate(0, 0, -10) // 10 days ago futureNextDue := time.Now().AddDate(0, 0, 60) // 60 days from now task := &models.Task{ DueDate: &pastDueDate, NextDueDate: &futureNextDue, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) // Should be upcoming (60 days > 30 day threshold), not overdue assert.Equal(t, ColumnUpcoming, result) }) } func TestDueSoonHandler(t *testing.T) { chain := NewChain() t.Run("task due within threshold goes to due_soon", func(t *testing.T) { dueDate := time.Now().AddDate(0, 0, 15) // 15 days from now task := &models.Task{ NextDueDate: &dueDate, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) // 30 day threshold assert.Equal(t, ColumnDueSoon, result) }) t.Run("task due exactly at threshold goes to due_soon", func(t *testing.T) { dueDate := time.Now().AddDate(0, 0, 29) // Just under 30 days task := &models.Task{ NextDueDate: &dueDate, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnDueSoon, result) }) t.Run("custom threshold is respected", func(t *testing.T) { dueDate := time.Now().AddDate(0, 0, 10) // 10 days from now task := &models.Task{ NextDueDate: &dueDate, Status: &models.TaskStatus{Name: "Pending"}, } // With 7 day threshold, 10 days out should be upcoming, not due_soon result := chain.Categorize(task, 7) assert.Equal(t, ColumnUpcoming, result) }) } func TestUpcomingHandler(t *testing.T) { chain := NewChain() t.Run("task with future next_due_date beyond threshold goes to upcoming", func(t *testing.T) { futureDate := time.Now().AddDate(0, 0, 60) // 60 days from now task := &models.Task{ NextDueDate: &futureDate, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnUpcoming, result) }) t.Run("task with no due date goes to upcoming (default)", func(t *testing.T) { task := &models.Task{ DueDate: nil, NextDueDate: nil, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnUpcoming, result) }) } func TestChainPriorityOrder(t *testing.T) { chain := NewChain() t.Run("cancelled takes priority over everything", func(t *testing.T) { pastDate := time.Now().AddDate(0, 0, -10) task := &models.Task{ IsCancelled: true, DueDate: &pastDate, NextDueDate: nil, Completions: []models.TaskCompletion{makeCompletion(1)}, Status: &models.TaskStatus{Name: "In Progress"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnCancelled, result) }) t.Run("completed takes priority over in_progress", func(t *testing.T) { task := &models.Task{ IsCancelled: false, NextDueDate: nil, Completions: []models.TaskCompletion{makeCompletion(1)}, Status: &models.TaskStatus{Name: "In Progress"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnCompleted, result) }) t.Run("in_progress takes priority over overdue", func(t *testing.T) { pastDate := time.Now().AddDate(0, 0, -10) task := &models.Task{ IsCancelled: false, NextDueDate: &pastDate, Status: &models.TaskStatus{Name: "In Progress"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnInProgress, result) }) t.Run("overdue takes priority over due_soon", func(t *testing.T) { pastDate := time.Now().AddDate(0, 0, -1) task := &models.Task{ IsCancelled: false, NextDueDate: &pastDate, Status: &models.TaskStatus{Name: "Pending"}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnOverdue, result) }) } func TestRecurringTaskScenarios(t *testing.T) { chain := NewChain() t.Run("annual task just completed should go to upcoming (next_due_date is 1 year out)", func(t *testing.T) { nextYear := time.Now().AddDate(1, 0, 0) task := &models.Task{ NextDueDate: &nextYear, Completions: []models.TaskCompletion{makeCompletion(1)}, Status: &models.TaskStatus{Name: "Pending"}, // Reset after completion Frequency: &models.TaskFrequency{Name: "Annually", Days: intPtr(365)}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnUpcoming, result) }) t.Run("monthly task due in 2 weeks should go to due_soon", func(t *testing.T) { twoWeeks := time.Now().AddDate(0, 0, 14) task := &models.Task{ NextDueDate: &twoWeeks, Completions: []models.TaskCompletion{makeCompletion(1)}, Status: &models.TaskStatus{Name: "Pending"}, Frequency: &models.TaskFrequency{Name: "Monthly", Days: intPtr(30)}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnDueSoon, result) }) t.Run("weekly task that is overdue should go to overdue", func(t *testing.T) { yesterday := time.Now().AddDate(0, 0, -1) task := &models.Task{ NextDueDate: &yesterday, Completions: []models.TaskCompletion{makeCompletion(1)}, Status: &models.TaskStatus{Name: "Pending"}, Frequency: &models.TaskFrequency{Name: "Weekly", Days: intPtr(7)}, } result := chain.Categorize(task, 30) assert.Equal(t, ColumnOverdue, result) }) } func TestCategorizeTasksIntoColumns(t *testing.T) { now := time.Now() pastDate := now.AddDate(0, 0, -5) soonDate := now.AddDate(0, 0, 15) futureDate := now.AddDate(0, 0, 60) // Create tasks with proper IDs task1 := makeTask(1) task1.IsCancelled = true task2 := makeTask(2) task2.NextDueDate = nil task2.Completions = []models.TaskCompletion{makeCompletion(1)} task3 := makeTask(3) task3.Status = &models.TaskStatus{Name: "In Progress"} task4 := makeTask(4) task4.NextDueDate = &pastDate task4.Status = &models.TaskStatus{Name: "Pending"} task5 := makeTask(5) task5.NextDueDate = &soonDate task5.Status = &models.TaskStatus{Name: "Pending"} task6 := makeTask(6) task6.NextDueDate = &futureDate task6.Status = &models.TaskStatus{Name: "Pending"} tasks := []models.Task{task1, task2, task3, task4, task5, task6} result := CategorizeTasksIntoColumns(tasks, 30) assert.Len(t, result[ColumnCancelled], 1) assert.Equal(t, uint(1), result[ColumnCancelled][0].ID) assert.Len(t, result[ColumnCompleted], 1) assert.Equal(t, uint(2), result[ColumnCompleted][0].ID) assert.Len(t, result[ColumnInProgress], 1) assert.Equal(t, uint(3), result[ColumnInProgress][0].ID) assert.Len(t, result[ColumnOverdue], 1) assert.Equal(t, uint(4), result[ColumnOverdue][0].ID) assert.Len(t, result[ColumnDueSoon], 1) assert.Equal(t, uint(5), result[ColumnDueSoon][0].ID) assert.Len(t, result[ColumnUpcoming], 1) assert.Equal(t, uint(6), result[ColumnUpcoming][0].ID) } func TestDefaultThreshold(t *testing.T) { task := &models.Task{} // Test that 0 or negative threshold defaults to 30 ctx1 := NewContext(task, 0) assert.Equal(t, 30, ctx1.DaysThreshold) ctx2 := NewContext(task, -5) assert.Equal(t, 30, ctx2.DaysThreshold) ctx3 := NewContext(task, 14) assert.Equal(t, 14, ctx3.DaysThreshold) } // Helper to create int pointer func intPtr(v int) *int { return &v }