Files
honeyDueAPI/internal/task/categorization/chain_test.go
Trey t cfb8a28870 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>
2025-12-07 11:48:03 -06:00

237 lines
7.6 KiB
Go

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)
}
}