package categorization_test import ( "testing" "time" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/task/categorization" ) // Helper to create a time pointer func timePtr(t time.Time) *time.Time { return &t } func TestCategorizeTask_PriorityOrder(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) inProgressStatus := &models.TaskStatus{Name: "In Progress"} daysThreshold := 30 tests := []struct { name string task *models.Task expected categorization.KanbanColumn }{ // Priority 1: Cancelled { name: "cancelled takes priority over everything", task: &models.Task{ IsCancelled: true, NextDueDate: timePtr(yesterday), // Would be overdue Status: inProgressStatus, // Would be in progress Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil }, expected: categorization.ColumnCancelled, }, // Priority 2: Completed { name: "completed: NextDueDate nil with completions", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: nil, DueDate: timePtr(yesterday), // Would be overdue if not completed Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, expected: categorization.ColumnCompleted, }, { name: "not completed when NextDueDate set (recurring task with completions)", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(in5Days), Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, expected: categorization.ColumnDueSoon, // Falls through to due soon }, // Priority 3: In Progress { name: "in progress takes priority over overdue", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(yesterday), // Would be overdue Status: inProgressStatus, Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnInProgress, }, // Priority 4: Overdue { name: "overdue: effective date in past", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(yesterday), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnOverdue, }, { name: "overdue: uses DueDate when NextDueDate nil (no completions)", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: nil, DueDate: timePtr(yesterday), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnOverdue, }, // Priority 5: Due Soon { name: "due soon: within threshold", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(in5Days), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnDueSoon, }, // Priority 6: Upcoming (default) { name: "upcoming: beyond threshold", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(in60Days), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnUpcoming, }, { name: "upcoming: no due date", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: nil, DueDate: nil, Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnUpcoming, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := categorization.CategorizeTask(tt.task, daysThreshold) if result != tt.expected { t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected) } }) } } func TestCategorizeTasksIntoColumns(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) daysThreshold := 30 tasks := []models.Task{ {BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled {BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed {BaseModel: models.BaseModel{ID: 3}, Status: &models.TaskStatus{Name: "In Progress"}}, // In Progress {BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue {BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon {BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming {BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date) } result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) // Check each column has the expected tasks if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 { t.Errorf("Expected task 1 in Cancelled column") } if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 { t.Errorf("Expected task 2 in Completed column") } if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 { t.Errorf("Expected task 3 in InProgress column") } if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 { t.Errorf("Expected task 4 in Overdue column") } if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 { t.Errorf("Expected task 5 in DueSoon column") } if len(result[categorization.ColumnUpcoming]) != 2 { t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming])) } } func TestDetermineKanbanColumn_ReturnsString(t *testing.T) { task := &models.Task{IsCancelled: true} result := categorization.DetermineKanbanColumn(task, 30) if result != "cancelled_tasks" { t.Errorf("DetermineKanbanColumn() = %v, expected %v", result, "cancelled_tasks") } } func TestKanbanColumnConstants(t *testing.T) { // Verify column string values match expected API values tests := []struct { column categorization.KanbanColumn expected string }{ {categorization.ColumnOverdue, "overdue_tasks"}, {categorization.ColumnDueSoon, "due_soon_tasks"}, {categorization.ColumnUpcoming, "upcoming_tasks"}, {categorization.ColumnInProgress, "in_progress_tasks"}, {categorization.ColumnCompleted, "completed_tasks"}, {categorization.ColumnCancelled, "cancelled_tasks"}, } for _, tt := range tests { if tt.column.String() != tt.expected { t.Errorf("Column %v.String() = %v, expected %v", tt.column, tt.column.String(), tt.expected) } } } func TestNewContext_DefaultThreshold(t *testing.T) { task := &models.Task{} // Zero threshold should default to 30 ctx := categorization.NewContext(task, 0) if ctx.DaysThreshold != 30 { t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold) } // Negative threshold should default to 30 ctx = categorization.NewContext(task, -5) if ctx.DaysThreshold != 30 { t.Errorf("NewContext with negative threshold should default to 30, got %d", ctx.DaysThreshold) } // Positive threshold should be used ctx = categorization.NewContext(task, 45) if ctx.DaysThreshold != 45 { t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold) } }