- Webhook event logging repo and subscription webhook idempotency - Pagination helper (echohelpers) with cursor/offset support - Request ID and structured logging middleware - Push client improvements (FCM HTTP v1, better error handling) - Task model version column, business constraint migrations, targeted indexes - Expanded categorization chain tests - Email service and config hardening - CI workflow updates, .gitignore additions, .env.example updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
804 lines
28 KiB
Go
804 lines
28 KiB
Go
package categorization_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-api/internal/task/categorization"
|
|
)
|
|
|
|
// Ensure assert is used (referenced in fuzz/property tests below)
|
|
var _ = assert.Equal
|
|
|
|
// 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)
|
|
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
|
|
InProgress: true, // 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
|
|
InProgress: true,
|
|
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}, InProgress: true}, // 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)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TIMEZONE TESTS
|
|
// These tests verify that kanban categorization works correctly across timezones.
|
|
// The key insight: a task's due date is stored as a date (YYYY-MM-DD), but
|
|
// categorization depends on "what day is it NOW" in the user's timezone.
|
|
// ============================================================================
|
|
|
|
func TestTimezone_SameTaskDifferentCategorization(t *testing.T) {
|
|
// Scenario: A task due on Dec 17, 2025
|
|
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC)
|
|
// But 8 AM on Dec 17 in Tokyo (+9 hours)
|
|
// The task should be "due_soon" for UTC user but already in "due_soon" for Tokyo
|
|
// (not overdue yet for either - both are still on or before Dec 17)
|
|
|
|
// Task due Dec 17, 2025 (stored as midnight UTC)
|
|
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
|
|
|
task := &models.Task{
|
|
NextDueDate: timePtr(taskDueDate),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// User in UTC: It's Dec 16, 2025 at 11 PM UTC
|
|
utcTime := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
|
|
|
|
// User in Tokyo: Same instant but it's Dec 17, 2025 at 8 AM local
|
|
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
|
tokyoTime := utcTime.In(tokyo) // Same instant, different representation
|
|
|
|
// For UTC user: Dec 17 is tomorrow (1 day away) - should be due_soon
|
|
resultUTC := categorization.CategorizeTaskWithTime(task, 30, utcTime)
|
|
if resultUTC != categorization.ColumnDueSoon {
|
|
t.Errorf("UTC (Dec 16): expected due_soon, got %v", resultUTC)
|
|
}
|
|
|
|
// For Tokyo user: Dec 17 is TODAY - should still be due_soon (not overdue)
|
|
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
|
|
if resultTokyo != categorization.ColumnDueSoon {
|
|
t.Errorf("Tokyo (Dec 17): expected due_soon, got %v", resultTokyo)
|
|
}
|
|
}
|
|
|
|
func TestTimezone_TaskBecomesOverdue_DifferentTimezones(t *testing.T) {
|
|
// Scenario: A task due on Dec 16, 2025
|
|
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC) - due_soon
|
|
// At 8 AM UTC on Dec 17 - now overdue
|
|
// But for Tokyo user at 11 PM UTC (8 AM Dec 17 Tokyo) - already overdue
|
|
|
|
taskDueDate := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
|
|
|
|
task := &models.Task{
|
|
NextDueDate: timePtr(taskDueDate),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// Case 1: UTC user at 11 PM on Dec 16 - task is due TODAY, so due_soon
|
|
utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
|
|
resultUTCEvening := categorization.CategorizeTaskWithTime(task, 30, utcDec16Evening)
|
|
if resultUTCEvening != categorization.ColumnDueSoon {
|
|
t.Errorf("UTC Dec 16 evening: expected due_soon, got %v", resultUTCEvening)
|
|
}
|
|
|
|
// Case 2: UTC user at 8 AM on Dec 17 - task is now OVERDUE
|
|
utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC)
|
|
resultUTCMorning := categorization.CategorizeTaskWithTime(task, 30, utcDec17Morning)
|
|
if resultUTCMorning != categorization.ColumnOverdue {
|
|
t.Errorf("UTC Dec 17 morning: expected overdue, got %v", resultUTCMorning)
|
|
}
|
|
|
|
// Case 3: Tokyo user at the same instant as case 1
|
|
// 11 PM UTC = 8 AM Dec 17 in Tokyo
|
|
// For Tokyo user, Dec 16 was yesterday, so task is OVERDUE
|
|
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
|
tokyoTime := utcDec16Evening.In(tokyo)
|
|
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
|
|
if resultTokyo != categorization.ColumnOverdue {
|
|
t.Errorf("Tokyo (same instant as UTC Dec 16 evening): expected overdue, got %v", resultTokyo)
|
|
}
|
|
}
|
|
|
|
func TestTimezone_InternationalDateLine(t *testing.T) {
|
|
// Test across the international date line
|
|
// Auckland (UTC+13) vs Honolulu (UTC-10)
|
|
// 23 hour difference!
|
|
|
|
// Task due Dec 17, 2025
|
|
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
|
|
|
task := &models.Task{
|
|
NextDueDate: timePtr(taskDueDate),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// At midnight UTC on Dec 17
|
|
utcTime := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Auckland: Dec 17 midnight UTC = Dec 17, 1 PM local (UTC+13)
|
|
// Task is due today in Auckland - should be due_soon
|
|
auckland, _ := time.LoadLocation("Pacific/Auckland")
|
|
aucklandTime := utcTime.In(auckland)
|
|
resultAuckland := categorization.CategorizeTaskWithTime(task, 30, aucklandTime)
|
|
if resultAuckland != categorization.ColumnDueSoon {
|
|
t.Errorf("Auckland (Dec 17, 1 PM): expected due_soon, got %v", resultAuckland)
|
|
}
|
|
|
|
// Honolulu: Dec 17 midnight UTC = Dec 16, 2 PM local (UTC-10)
|
|
// Task is due tomorrow in Honolulu - should be due_soon
|
|
honolulu, _ := time.LoadLocation("Pacific/Honolulu")
|
|
honoluluTime := utcTime.In(honolulu)
|
|
resultHonolulu := categorization.CategorizeTaskWithTime(task, 30, honoluluTime)
|
|
if resultHonolulu != categorization.ColumnDueSoon {
|
|
t.Errorf("Honolulu (Dec 16, 2 PM): expected due_soon, got %v", resultHonolulu)
|
|
}
|
|
|
|
// Now advance to Dec 18 midnight UTC
|
|
// Auckland: Dec 18, 1 PM local - task due Dec 17 is now OVERDUE
|
|
// Honolulu: Dec 17, 2 PM local - task due Dec 17 is TODAY (due_soon)
|
|
utcDec18 := time.Date(2025, 12, 18, 0, 0, 0, 0, time.UTC)
|
|
|
|
aucklandDec18 := utcDec18.In(auckland)
|
|
resultAuckland2 := categorization.CategorizeTaskWithTime(task, 30, aucklandDec18)
|
|
if resultAuckland2 != categorization.ColumnOverdue {
|
|
t.Errorf("Auckland (Dec 18): expected overdue, got %v", resultAuckland2)
|
|
}
|
|
|
|
honoluluDec17 := utcDec18.In(honolulu)
|
|
resultHonolulu2 := categorization.CategorizeTaskWithTime(task, 30, honoluluDec17)
|
|
if resultHonolulu2 != categorization.ColumnDueSoon {
|
|
t.Errorf("Honolulu (Dec 17): expected due_soon, got %v", resultHonolulu2)
|
|
}
|
|
}
|
|
|
|
func TestTimezone_DueSoonThreshold_CrossesTimezones(t *testing.T) {
|
|
// Test that the 30-day threshold is calculated correctly in different timezones
|
|
|
|
// Task due 29 days from now (within threshold for both timezones)
|
|
// Task due 31 days from now (outside threshold)
|
|
|
|
now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)
|
|
|
|
// Task due in 29 days
|
|
due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
|
|
task29 := &models.Task{
|
|
NextDueDate: timePtr(due29Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// Task due in 31 days
|
|
due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
|
|
task31 := &models.Task{
|
|
NextDueDate: timePtr(due31Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// UTC user
|
|
result29UTC := categorization.CategorizeTaskWithTime(task29, 30, now)
|
|
if result29UTC != categorization.ColumnDueSoon {
|
|
t.Errorf("29 days (UTC): expected due_soon, got %v", result29UTC)
|
|
}
|
|
|
|
result31UTC := categorization.CategorizeTaskWithTime(task31, 30, now)
|
|
if result31UTC != categorization.ColumnUpcoming {
|
|
t.Errorf("31 days (UTC): expected upcoming, got %v", result31UTC)
|
|
}
|
|
|
|
// Tokyo user at same instant
|
|
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
|
tokyoNow := now.In(tokyo)
|
|
|
|
result29Tokyo := categorization.CategorizeTaskWithTime(task29, 30, tokyoNow)
|
|
if result29Tokyo != categorization.ColumnDueSoon {
|
|
t.Errorf("29 days (Tokyo): expected due_soon, got %v", result29Tokyo)
|
|
}
|
|
|
|
result31Tokyo := categorization.CategorizeTaskWithTime(task31, 30, tokyoNow)
|
|
if result31Tokyo != categorization.ColumnUpcoming {
|
|
t.Errorf("31 days (Tokyo): expected upcoming, got %v", result31Tokyo)
|
|
}
|
|
}
|
|
|
|
func TestTimezone_StartOfDayNormalization(t *testing.T) {
|
|
// Test that times are normalized to start of day in the given timezone
|
|
|
|
// A task due Dec 17
|
|
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
|
task := &models.Task{
|
|
NextDueDate: timePtr(taskDueDate),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// Test that different times on the SAME DAY produce the SAME result
|
|
// All of these should evaluate to "Dec 16" (today), making Dec 17 "due_soon"
|
|
times := []time.Time{
|
|
time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC), // Midnight
|
|
time.Date(2025, 12, 16, 6, 0, 0, 0, time.UTC), // 6 AM
|
|
time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC), // Noon
|
|
time.Date(2025, 12, 16, 18, 0, 0, 0, time.UTC), // 6 PM
|
|
time.Date(2025, 12, 16, 23, 59, 59, 0, time.UTC), // Just before midnight
|
|
}
|
|
|
|
for _, nowTime := range times {
|
|
result := categorization.CategorizeTaskWithTime(task, 30, nowTime)
|
|
if result != categorization.ColumnDueSoon {
|
|
t.Errorf("At %v: expected due_soon, got %v", nowTime.Format("15:04:05"), result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTimezone_DST_Transitions(t *testing.T) {
|
|
// Test behavior during daylight saving time transitions
|
|
// Los Angeles transitions from PDT to PST in early November
|
|
|
|
la, err := time.LoadLocation("America/Los_Angeles")
|
|
if err != nil {
|
|
t.Skip("America/Los_Angeles timezone not available")
|
|
}
|
|
|
|
// Task due Nov 3, 2025 (DST ends in LA on Nov 2, 2025)
|
|
taskDueDate := time.Date(2025, 11, 3, 0, 0, 0, 0, time.UTC)
|
|
task := &models.Task{
|
|
NextDueDate: timePtr(taskDueDate),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
// Nov 2 at 11 PM LA time (during DST transition)
|
|
// This should still be Nov 2, so Nov 3 is tomorrow (due_soon)
|
|
laNov2Late := time.Date(2025, 11, 2, 23, 0, 0, 0, la)
|
|
result := categorization.CategorizeTaskWithTime(task, 30, laNov2Late)
|
|
if result != categorization.ColumnDueSoon {
|
|
t.Errorf("Nov 2 late evening LA: expected due_soon, got %v", result)
|
|
}
|
|
|
|
// Nov 3 at 1 AM LA time (after DST ends)
|
|
// This is Nov 3, so task is due today (due_soon)
|
|
laNov3Early := time.Date(2025, 11, 3, 1, 0, 0, 0, la)
|
|
result = categorization.CategorizeTaskWithTime(task, 30, laNov3Early)
|
|
if result != categorization.ColumnDueSoon {
|
|
t.Errorf("Nov 3 early morning LA: expected due_soon, got %v", result)
|
|
}
|
|
|
|
// Nov 4 at any time (after due date)
|
|
laNov4 := time.Date(2025, 11, 4, 8, 0, 0, 0, la)
|
|
result = categorization.CategorizeTaskWithTime(task, 30, laNov4)
|
|
if result != categorization.ColumnOverdue {
|
|
t.Errorf("Nov 4 LA: expected overdue, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestTimezone_MultipleTasksIntoColumns(t *testing.T) {
|
|
// Test CategorizeTasksIntoColumnsWithTime with timezone-aware categorization
|
|
|
|
// Tasks with various due dates
|
|
dec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
|
|
dec17 := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
|
jan15 := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
|
|
|
|
tasks := []models.Task{
|
|
{BaseModel: models.BaseModel{ID: 1}, NextDueDate: timePtr(dec16)}, // Due Dec 16
|
|
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: timePtr(dec17)}, // Due Dec 17
|
|
{BaseModel: models.BaseModel{ID: 3}, NextDueDate: timePtr(jan15)}, // Due Jan 15
|
|
}
|
|
|
|
// Categorize as of Dec 17 midnight UTC
|
|
now := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
|
result := categorization.CategorizeTasksIntoColumnsWithTime(tasks, 30, now)
|
|
|
|
// Dec 16 should be overdue (yesterday)
|
|
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 1 {
|
|
t.Errorf("Expected task 1 (Dec 16) in overdue column, got %d tasks", len(result[categorization.ColumnOverdue]))
|
|
}
|
|
|
|
// Dec 17 (today) and Jan 15 (29 days away) should both be in due_soon
|
|
// Dec 17 to Jan 15 = 29 days (Dec 17-31 = 14 days, Jan 1-15 = 15 days)
|
|
dueSoonTasks := result[categorization.ColumnDueSoon]
|
|
if len(dueSoonTasks) != 2 {
|
|
t.Errorf("Expected 2 tasks in due_soon column, got %d", len(dueSoonTasks))
|
|
}
|
|
|
|
// Verify both task 2 and 3 are in due_soon
|
|
foundTask2 := false
|
|
foundTask3 := false
|
|
for _, task := range dueSoonTasks {
|
|
if task.ID == 2 {
|
|
foundTask2 = true
|
|
}
|
|
if task.ID == 3 {
|
|
foundTask3 = true
|
|
}
|
|
}
|
|
|
|
if !foundTask2 {
|
|
t.Errorf("Expected task 2 (Dec 17) in due_soon column")
|
|
}
|
|
if !foundTask3 {
|
|
t.Errorf("Expected task 3 (Jan 15) in due_soon column")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// FUZZ / PROPERTY TESTS
|
|
// These tests verify invariants that must hold for ALL possible task states,
|
|
// not just specific hand-crafted examples.
|
|
//
|
|
// validColumns is defined in chain_breakit_test.go and shared across test files
|
|
// in the categorization_test package.
|
|
// ============================================================================
|
|
|
|
// FuzzCategorizeTaskExtended feeds random task states into CategorizeTask using
|
|
// separate boolean flags for date presence and day-offset integers for date
|
|
// values. This complements FuzzCategorizeTask (in chain_breakit_test.go) by
|
|
// exercising the nil-date paths more directly.
|
|
func FuzzCategorizeTaskExtended(f *testing.F) {
|
|
// Seed corpus: cover a representative spread of boolean/date combinations.
|
|
// isCancelled, isArchived, inProgress, hasCompletions,
|
|
// hasDueDate, dueDateOffsetDays, hasNextDueDate, nextDueDateOffsetDays
|
|
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) // completed (no next due, has completions)
|
|
f.Add(false, false, false, false, true, -5, false, 0) // overdue via DueDate
|
|
f.Add(false, false, false, false, false, 0, true, -5) // overdue via NextDueDate
|
|
f.Add(false, false, false, false, false, 0, true, 5) // due soon
|
|
f.Add(false, false, false, false, false, 0, true, 60) // upcoming
|
|
f.Add(true, true, true, true, true, -10, true, -10) // everything set
|
|
f.Add(false, false, false, false, true, 100, true, 100) // far future
|
|
|
|
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)
|
|
|
|
// Invariant 1: result must never be the empty string.
|
|
if result == "" {
|
|
t.Fatalf("CategorizeTask returned empty string for task %+v", task)
|
|
}
|
|
|
|
// Invariant 2: result must be one of the valid KanbanColumn constants.
|
|
if !validColumns[result] {
|
|
t.Fatalf("CategorizeTask returned invalid column %q for task %+v", result, task)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCategorizeTask_MutuallyExclusive exhaustively enumerates all boolean
|
|
// state combinations (IsCancelled, IsArchived, InProgress, hasCompletions)
|
|
// crossed with representative date positions (no date, past, today, within
|
|
// threshold, beyond threshold) and asserts that every task maps to exactly
|
|
// one valid, non-empty column.
|
|
func TestCategorizeTask_MutuallyExclusive(t *testing.T) {
|
|
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
|
|
daysThreshold := 30
|
|
|
|
// Date scenarios relative to "now" for both DueDate and NextDueDate.
|
|
type dateScenario struct {
|
|
name string
|
|
dueDate *time.Time
|
|
nextDue *time.Time
|
|
}
|
|
|
|
past := now.AddDate(0, 0, -5)
|
|
today := now
|
|
withinThreshold := now.AddDate(0, 0, 10)
|
|
beyondThreshold := now.AddDate(0, 0, 60)
|
|
|
|
dateScenarios := []dateScenario{
|
|
{"no dates", nil, nil},
|
|
{"DueDate past only", &past, nil},
|
|
{"DueDate today only", &today, nil},
|
|
{"DueDate within threshold", &withinThreshold, nil},
|
|
{"DueDate beyond threshold", &beyondThreshold, nil},
|
|
{"NextDueDate past", nil, &past},
|
|
{"NextDueDate today", nil, &today},
|
|
{"NextDueDate within threshold", nil, &withinThreshold},
|
|
{"NextDueDate beyond threshold", nil, &beyondThreshold},
|
|
{"both past", &past, &past},
|
|
{"DueDate past NextDueDate future", &past, &withinThreshold},
|
|
{"both beyond threshold", &beyondThreshold, &beyondThreshold},
|
|
}
|
|
|
|
boolCombos := []struct {
|
|
cancelled, archived, inProgress, hasCompletions bool
|
|
}{
|
|
{false, false, false, false},
|
|
{true, false, false, false},
|
|
{false, true, false, false},
|
|
{false, false, true, false},
|
|
{false, false, false, true},
|
|
{true, true, false, false},
|
|
{true, false, true, false},
|
|
{true, false, false, true},
|
|
{false, true, true, false},
|
|
{false, true, false, true},
|
|
{false, false, true, true},
|
|
{true, true, true, false},
|
|
{true, true, false, true},
|
|
{true, false, true, true},
|
|
{false, true, true, true},
|
|
{true, true, true, true},
|
|
}
|
|
|
|
for _, ds := range dateScenarios {
|
|
for _, bc := range boolCombos {
|
|
task := &models.Task{
|
|
IsCancelled: bc.cancelled,
|
|
IsArchived: bc.archived,
|
|
InProgress: bc.inProgress,
|
|
DueDate: ds.dueDate,
|
|
NextDueDate: ds.nextDue,
|
|
}
|
|
if bc.hasCompletions {
|
|
task.Completions = []models.TaskCompletion{
|
|
{BaseModel: models.BaseModel{ID: 1}},
|
|
}
|
|
} else {
|
|
task.Completions = []models.TaskCompletion{}
|
|
}
|
|
|
|
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
|
|
|
|
assert.NotEmpty(t, result,
|
|
"empty column for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
|
|
ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
|
|
|
|
assert.True(t, validColumns[result],
|
|
"invalid column %q for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
|
|
result, ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCategorizeTask_CancelledAlwaysCancelled verifies the property that any
|
|
// task with IsCancelled=true is always categorized into ColumnCancelled,
|
|
// regardless of all other field values.
|
|
func TestCategorizeTask_CancelledAlwaysCancelled(t *testing.T) {
|
|
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
|
|
daysThreshold := 30
|
|
|
|
past := now.AddDate(0, 0, -5)
|
|
future := now.AddDate(0, 0, 10)
|
|
farFuture := now.AddDate(0, 0, 60)
|
|
|
|
dates := []*time.Time{nil, &past, &future, &farFuture}
|
|
bools := []bool{true, false}
|
|
|
|
for _, isArchived := range bools {
|
|
for _, inProgress := range bools {
|
|
for _, hasCompletions := range bools {
|
|
for _, dueDate := range dates {
|
|
for _, nextDueDate := range dates {
|
|
task := &models.Task{
|
|
IsCancelled: true, // always cancelled
|
|
IsArchived: isArchived,
|
|
InProgress: inProgress,
|
|
DueDate: dueDate,
|
|
NextDueDate: nextDueDate,
|
|
}
|
|
if hasCompletions {
|
|
task.Completions = []models.TaskCompletion{
|
|
{BaseModel: models.BaseModel{ID: 1}},
|
|
}
|
|
} else {
|
|
task.Completions = []models.TaskCompletion{}
|
|
}
|
|
|
|
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
|
|
|
|
assert.Equal(t, categorization.ColumnCancelled, result,
|
|
"cancelled task should always map to ColumnCancelled, got %q "+
|
|
"(archived=%v inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
|
|
result, isArchived, inProgress, hasCompletions, dueDate, nextDueDate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCategorizeTask_ArchivedAlwaysArchived verifies the property that any
|
|
// task with IsArchived=true and IsCancelled=false is always categorized into
|
|
// ColumnCancelled (archived tasks share the cancelled column as both represent
|
|
// "inactive" states), regardless of all other field values.
|
|
func TestCategorizeTask_ArchivedAlwaysArchived(t *testing.T) {
|
|
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
|
|
daysThreshold := 30
|
|
|
|
past := now.AddDate(0, 0, -5)
|
|
future := now.AddDate(0, 0, 10)
|
|
farFuture := now.AddDate(0, 0, 60)
|
|
|
|
dates := []*time.Time{nil, &past, &future, &farFuture}
|
|
bools := []bool{true, false}
|
|
|
|
for _, inProgress := range bools {
|
|
for _, hasCompletions := range bools {
|
|
for _, dueDate := range dates {
|
|
for _, nextDueDate := range dates {
|
|
task := &models.Task{
|
|
IsCancelled: false, // not cancelled
|
|
IsArchived: true, // always archived
|
|
InProgress: inProgress,
|
|
DueDate: dueDate,
|
|
NextDueDate: nextDueDate,
|
|
}
|
|
if hasCompletions {
|
|
task.Completions = []models.TaskCompletion{
|
|
{BaseModel: models.BaseModel{ID: 1}},
|
|
}
|
|
} else {
|
|
task.Completions = []models.TaskCompletion{}
|
|
}
|
|
|
|
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
|
|
|
|
assert.Equal(t, categorization.ColumnCancelled, result,
|
|
"archived (non-cancelled) task should always map to ColumnCancelled, got %q "+
|
|
"(inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
|
|
result, inProgress, hasCompletions, dueDate, nextDueDate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|