package categorization_test import ( "math/rand" "testing" "time" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/task/categorization" ) // validColumns is the complete set of KanbanColumn values the chain may return. var validColumns = map[categorization.KanbanColumn]bool{ categorization.ColumnOverdue: true, categorization.ColumnDueSoon: true, categorization.ColumnUpcoming: true, categorization.ColumnInProgress: true, categorization.ColumnCompleted: true, categorization.ColumnCancelled: true, } // FuzzCategorizeTask feeds random task states into CategorizeTask and asserts // that the result is always a non-empty, valid KanbanColumn constant. func FuzzCategorizeTask(f *testing.F) { f.Add(false, false, false, false, false, 0, false, 0) f.Add(true, false, false, false, false, 0, false, 0) f.Add(false, true, false, false, false, 0, false, 0) f.Add(false, false, true, false, false, 0, false, 0) f.Add(false, false, false, true, false, 0, false, 0) f.Add(false, false, false, false, true, -5, false, 0) f.Add(false, false, false, false, false, 0, true, -5) f.Add(false, false, false, false, false, 0, true, 5) f.Add(false, false, false, false, false, 0, true, 60) f.Add(true, true, true, true, true, -10, true, -10) f.Add(false, false, false, false, true, 100, true, 100) f.Fuzz(func(t *testing.T, isCancelled, isArchived, inProgress, hasCompletions bool, hasDueDate bool, dueDateOffsetDays int, hasNextDueDate bool, nextDueDateOffsetDays int, ) { now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) task := &models.Task{ IsCancelled: isCancelled, IsArchived: isArchived, InProgress: inProgress, } if hasDueDate { d := now.AddDate(0, 0, dueDateOffsetDays) task.DueDate = &d } if hasNextDueDate { d := now.AddDate(0, 0, nextDueDateOffsetDays) task.NextDueDate = &d } if hasCompletions { task.Completions = []models.TaskCompletion{ {BaseModel: models.BaseModel{ID: 1}}, } } else { task.Completions = []models.TaskCompletion{} } result := categorization.CategorizeTask(task, 30) if result == "" { t.Fatalf("CategorizeTask returned empty string for task %+v", task) } if !validColumns[result] { t.Fatalf("CategorizeTask returned invalid column %q for task %+v", result, task) } }) } // === Property Tests (1000 random tasks) === // TestCategorizeTask_PropertyEveryTaskMapsToExactlyOneColumn uses random tasks // to validate the property that every task maps to exactly one column. func TestCategorizeTask_PropertyEveryTaskMapsToExactlyOneColumn(t *testing.T) { rng := rand.New(rand.NewSource(42)) // Deterministic seed for reproducibility now := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 1000; i++ { task := randomTask(rng, now) column := categorization.CategorizeTask(task, 30) if !validColumns[column] { t.Fatalf("Task %d mapped to invalid column %q: %+v", i, column, task) } } } // TestCategorizeTask_CancelledAlwaysWins validates that cancelled takes priority // over all other states regardless of other flags using randomized tasks. func TestCategorizeTask_CancelledAlwaysWins(t *testing.T) { rng := rand.New(rand.NewSource(42)) now := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 500; i++ { task := randomTask(rng, now) task.IsCancelled = true column := categorization.CategorizeTask(task, 30) if column != categorization.ColumnCancelled { t.Fatalf("Cancelled task %d mapped to %q instead of cancelled_tasks: %+v", i, column, task) } } } // === Timezone / DST Boundary Tests === // TestCategorizeTask_UTCMidnightBoundary tests task categorization at exactly // UTC midnight, which is the boundary between days. func TestCategorizeTask_UTCMidnightBoundary(t *testing.T) { midnight := time.Date(2025, 3, 9, 0, 0, 0, 0, time.UTC) dueDate := midnight task := &models.Task{ DueDate: &dueDate, } // At midnight of the due date, task is NOT overdue (due today) column := categorization.CategorizeTaskWithTime(task, 30, midnight) if column == categorization.ColumnOverdue { t.Errorf("Task due today should not be overdue at midnight, got %q", column) } // One day later, task IS overdue nextDay := midnight.AddDate(0, 0, 1) column = categorization.CategorizeTaskWithTime(task, 30, nextDay) if column != categorization.ColumnOverdue { t.Errorf("Task due yesterday should be overdue, got %q", column) } } // TestCategorizeTask_DSTSpringForward tests categorization across DST spring-forward. // In US Eastern time, 2:00 AM jumps to 3:00 AM on the second Sunday of March. func TestCategorizeTask_DSTSpringForward(t *testing.T) { loc, err := time.LoadLocation("America/New_York") if err != nil { t.Skip("America/New_York timezone not available") } // March 9, 2025 is DST spring-forward in Eastern Time dueDate := time.Date(2025, 3, 9, 0, 0, 0, 0, time.UTC) // Stored as UTC task := &models.Task{DueDate: &dueDate} // Check at start of March 9 in Eastern time nowET := time.Date(2025, 3, 9, 0, 0, 0, 0, loc) column := categorization.CategorizeTaskWithTime(task, 30, nowET) if column == categorization.ColumnOverdue { t.Errorf("Task due March 9 should not be overdue on March 9 (DST spring-forward), got %q", column) } // Check at March 10 - should be overdue now nextDayET := time.Date(2025, 3, 10, 0, 0, 0, 0, loc) column = categorization.CategorizeTaskWithTime(task, 30, nextDayET) if column != categorization.ColumnOverdue { t.Errorf("Task due March 9 should be overdue on March 10, got %q", column) } } // TestCategorizeTask_DSTFallBack tests categorization across DST fall-back. // In US Eastern time, 2:00 AM jumps back to 1:00 AM on the first Sunday of November. func TestCategorizeTask_DSTFallBack(t *testing.T) { loc, err := time.LoadLocation("America/New_York") if err != nil { t.Skip("America/New_York timezone not available") } // November 2, 2025 is DST fall-back in Eastern Time dueDate := time.Date(2025, 11, 2, 0, 0, 0, 0, time.UTC) task := &models.Task{DueDate: &dueDate} // On the due date itself - not overdue nowET := time.Date(2025, 11, 2, 0, 0, 0, 0, loc) column := categorization.CategorizeTaskWithTime(task, 30, nowET) if column == categorization.ColumnOverdue { t.Errorf("Task due Nov 2 should not be overdue on Nov 2 (DST fall-back), got %q", column) } // Next day - should be overdue nextDayET := time.Date(2025, 11, 3, 0, 0, 0, 0, loc) column = categorization.CategorizeTaskWithTime(task, 30, nextDayET) if column != categorization.ColumnOverdue { t.Errorf("Task due Nov 2 should be overdue on Nov 3, got %q", column) } } // TestIsOverdue_UTCMidnightEdge validates the overdue predicate at exact midnight. func TestIsOverdue_UTCMidnightEdge(t *testing.T) { dueDate := time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC) task := &models.Task{DueDate: &dueDate} // On due date: NOT overdue atDueDate := time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC) column := categorization.CategorizeTaskWithTime(task, 30, atDueDate) if column == categorization.ColumnOverdue { t.Error("Task should not be overdue on its due date") } // One second after midnight next day: overdue afterDueDate := time.Date(2026, 1, 1, 0, 0, 1, 0, time.UTC) column = categorization.CategorizeTaskWithTime(task, 30, afterDueDate) if column != categorization.ColumnOverdue { t.Errorf("Task should be overdue after its due date, got %q", column) } } // === Helper === func randomTask(rng *rand.Rand, baseTime time.Time) *models.Task { task := &models.Task{ IsCancelled: rng.Intn(10) == 0, // 10% chance IsArchived: rng.Intn(10) == 0, // 10% chance InProgress: rng.Intn(5) == 0, // 20% chance } if rng.Intn(4) > 0 { // 75% have due date d := baseTime.AddDate(0, 0, rng.Intn(120)-60) task.DueDate = &d } if rng.Intn(3) == 0 { // 33% recurring d := baseTime.AddDate(0, 0, rng.Intn(120)-60) task.NextDueDate = &d } if rng.Intn(3) == 0 { // 33% have completions count := rng.Intn(3) + 1 for i := 0; i < count; i++ { task.Completions = append(task.Completions, models.TaskCompletion{ BaseModel: models.BaseModel{ID: uint(i + 1)}, }) } } return task }