Consolidate task logic into single source of truth (DRY refactor)

This refactor eliminates duplicate task logic across the codebase by
creating a centralized task package with three layers:

- predicates/: Pure Go functions defining task state logic (IsCompleted,
  IsOverdue, IsDueSoon, IsUpcoming, IsActive, IsInProgress, EffectiveDate)
- scopes/: GORM scope functions mirroring predicates for database queries
- categorization/: Chain of Responsibility pattern for kanban column assignment

Key fixes:
- Fixed PostgreSQL DATE vs TIMESTAMP comparison bug in scopes (added
  explicit ::timestamp casts) that caused summary/kanban count mismatches
- Fixed models/task.go IsOverdue() and IsDueSoon() to use EffectiveDate
  (NextDueDate ?? DueDate) instead of only DueDate
- Removed duplicate isTaskCompleted() helpers from task_repo.go and
  task_button_types.go

Files refactored to use consolidated logic:
- task_repo.go: Uses scopes for statistics, predicates for filtering
- task_button_types.go: Uses predicates instead of inline logic
- responses/task.go: Delegates to categorization package
- dashboard_handler.go: Uses scopes for task statistics
- residence_service.go: Uses predicates for report generation
- worker/jobs/handler.go: Documented SQL with predicate references

Added comprehensive tests:
- predicates_test.go: Unit tests for all predicate functions
- scopes_test.go: Integration tests verifying scopes match predicates
- consistency_test.go: Three-layer consistency tests ensuring predicates,
  scopes, and categorization all return identical results

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-07 11:48:03 -06:00
parent f0c7b070d7
commit cfb8a28870
16 changed files with 3408 additions and 679 deletions

View File

@@ -4,12 +4,16 @@
// The chain evaluates tasks in a specific priority order, with each handler
// checking if the task matches its criteria. If a handler matches, it returns
// the column name; otherwise, it passes to the next handler in the chain.
//
// IMPORTANT: This package uses predicates from the parent task package as the
// single source of truth for task logic. Do NOT duplicate logic here.
package categorization
import (
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/predicates"
)
// KanbanColumn represents the possible kanban column names
@@ -37,12 +41,12 @@ type Context struct {
}
// NewContext creates a new categorization context with sensible defaults
func NewContext(task *models.Task, daysThreshold int) *Context {
func NewContext(t *models.Task, daysThreshold int) *Context {
if daysThreshold <= 0 {
daysThreshold = 30
}
return &Context{
Task: task,
Task: t,
Now: time.Now().UTC(),
DaysThreshold: daysThreshold,
}
@@ -83,6 +87,7 @@ func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn {
}
// === Concrete Handlers ===
// Each handler uses predicates from the task package as the source of truth.
// CancelledHandler checks if the task is cancelled
// Priority: 1 (highest - checked first)
@@ -91,7 +96,8 @@ type CancelledHandler struct {
}
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
if ctx.Task.IsCancelled {
// Uses predicate: predicates.IsCancelled
if predicates.IsCancelled(ctx.Task) {
return ColumnCancelled
}
return h.HandleNext(ctx)
@@ -104,10 +110,9 @@ type CompletedHandler struct {
}
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
// A task is completed if:
// - It has at least one completion record
// - AND it has no NextDueDate (meaning it's a one-time task or the cycle is done)
if ctx.Task.NextDueDate == nil && len(ctx.Task.Completions) > 0 {
// Uses predicate: predicates.IsCompleted
// A task is completed if NextDueDate is nil AND has at least one completion
if predicates.IsCompleted(ctx.Task) {
return ColumnCompleted
}
return h.HandleNext(ctx)
@@ -120,7 +125,8 @@ type InProgressHandler struct {
}
func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
if ctx.Task.Status != nil && ctx.Task.Status.Name == "In Progress" {
// Uses predicate: predicates.IsInProgress
if predicates.IsInProgress(ctx.Task) {
return ColumnInProgress
}
return h.HandleNext(ctx)
@@ -133,22 +139,16 @@ type OverdueHandler struct {
}
func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
effectiveDate := h.getEffectiveDate(ctx.Task)
// Uses predicate: predicates.EffectiveDate
// Note: We don't use predicates.IsOverdue here because the chain has already
// filtered out cancelled and completed tasks. We just need the date check.
effectiveDate := predicates.EffectiveDate(ctx.Task)
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
return ColumnOverdue
}
return h.HandleNext(ctx)
}
func (h *OverdueHandler) getEffectiveDate(task *models.Task) *time.Time {
// Prefer NextDueDate for recurring tasks
if task.NextDueDate != nil {
return task.NextDueDate
}
// Fall back to DueDate for initial categorization
return task.DueDate
}
// DueSoonHandler checks if the task is due within the threshold period
// Priority: 5
type DueSoonHandler struct {
@@ -156,7 +156,8 @@ type DueSoonHandler struct {
}
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
effectiveDate := h.getEffectiveDate(ctx.Task)
// Uses predicate: predicates.EffectiveDate
effectiveDate := predicates.EffectiveDate(ctx.Task)
threshold := ctx.ThresholdDate()
if effectiveDate != nil && effectiveDate.Before(threshold) {
@@ -165,13 +166,6 @@ func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
return h.HandleNext(ctx)
}
func (h *DueSoonHandler) getEffectiveDate(task *models.Task) *time.Time {
if task.NextDueDate != nil {
return task.NextDueDate
}
return task.DueDate
}
// UpcomingHandler is the final handler that catches all remaining tasks
// Priority: 6 (lowest - default)
type UpcomingHandler struct {
@@ -179,7 +173,10 @@ type UpcomingHandler struct {
}
func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn {
// This is the default catch-all
// This is the default catch-all for tasks that:
// - Are not cancelled, completed, or in progress
// - Are not overdue or due soon
// - Have a due date far in the future OR no due date at all
return ColumnUpcoming
}
@@ -211,8 +208,8 @@ func NewChain() *Chain {
}
// Categorize determines which kanban column a task belongs to
func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn {
ctx := NewContext(task, daysThreshold)
func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn {
ctx := NewContext(t, daysThreshold)
return c.head.Handle(ctx)
}
@@ -227,13 +224,13 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
var defaultChain = NewChain()
// DetermineKanbanColumn is a convenience function that uses the default chain
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
return defaultChain.Categorize(task, daysThreshold).String()
func DetermineKanbanColumn(t *models.Task, daysThreshold int) string {
return defaultChain.Categorize(t, daysThreshold).String()
}
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
return defaultChain.Categorize(task, daysThreshold)
func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn {
return defaultChain.Categorize(t, daysThreshold)
}
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
@@ -250,9 +247,9 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb
// Categorize each task
chain := NewChain()
for _, task := range tasks {
column := chain.Categorize(&task, daysThreshold)
result[column] = append(result[column], task)
for _, t := range tasks {
column := chain.Categorize(&t, daysThreshold)
result[column] = append(result[column], t)
}
return result

View File

@@ -1,12 +1,11 @@
package categorization
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"
)
// Helper to create a time pointer
@@ -14,362 +13,224 @@ func timePtr(t time.Time) *time.Time {
return &t
}
// Helper to create a uint pointer
func uintPtr(v uint) *uint {
return &v
}
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
// 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
}
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,
},
// Helper to create a task with an ID
func makeTask(id uint) models.Task {
t := models.Task{}
t.ID = id
return t
}
// 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
},
func TestCancelledHandler(t *testing.T) {
chain := NewChain()
// 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,
},
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)
})
// 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,
},
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)
})
}
// 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,
},
func TestCompletedHandler(t *testing.T) {
chain := NewChain()
// 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,
},
}
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)
})
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()
pastDate := now.AddDate(0, 0, -5)
soonDate := now.AddDate(0, 0, 15)
futureDate := now.AddDate(0, 0, 60)
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Create tasks with proper IDs
task1 := makeTask(1)
task1.IsCancelled = true
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)
}
task2 := makeTask(2)
task2.NextDueDate = nil
task2.Completions = []models.TaskCompletion{makeCompletion(1)}
result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
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)
// 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 TestDefaultThreshold(t *testing.T) {
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{}
// Test that 0 or negative threshold defaults to 30
ctx1 := NewContext(task, 0)
assert.Equal(t, 30, ctx1.DaysThreshold)
// 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)
}
ctx2 := NewContext(task, -5)
assert.Equal(t, 30, ctx2.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)
}
ctx3 := NewContext(task, 14)
assert.Equal(t, 14, ctx3.DaysThreshold)
}
// Helper to create int pointer
func intPtr(v int) *int {
return &v
// 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)
}
}