Files
honeyDueAPI/internal/task/categorization/chain_test.go
Trey t 1b06c0639c Add actionable push notifications and fix recurring task completion
Features:
- Add task action buttons to push notifications (complete, view, cancel, etc.)
- Add button types logic for different task states (overdue, in_progress, etc.)
- Implement Chain of Responsibility pattern for task categorization
- Add comprehensive kanban categorization documentation

Fixes:
- Reset recurring task status to Pending after completion so tasks appear
  in correct kanban column (was staying in "In Progress")
- Fix PostgreSQL EXTRACT function error in overdue notifications query
- Update seed data to properly set next_due_date for recurring tasks

Admin:
- Add tasks list to residence detail page
- Fix task edit page to properly handle all fields

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 14:23:14 -06:00

376 lines
11 KiB
Go

package categorization
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/treytartt/casera-api/internal/models"
)
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
}
// Helper to create a uint pointer
func uintPtr(v uint) *uint {
return &v
}
// 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
}
// Helper to create a task with an ID
func makeTask(id uint) models.Task {
t := models.Task{}
t.ID = id
return t
}
func TestCancelledHandler(t *testing.T) {
chain := NewChain()
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)
})
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)
})
}
func TestCompletedHandler(t *testing.T) {
chain := NewChain()
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)
})
}
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)
// Create tasks with proper IDs
task1 := makeTask(1)
task1.IsCancelled = true
task2 := makeTask(2)
task2.NextDueDate = nil
task2.Completions = []models.TaskCompletion{makeCompletion(1)}
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)
}
func TestDefaultThreshold(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)
ctx2 := NewContext(task, -5)
assert.Equal(t, 30, ctx2.DaysThreshold)
ctx3 := NewContext(task, 14)
assert.Equal(t, 14, ctx3.DaysThreshold)
}
// Helper to create int pointer
func intPtr(v int) *int {
return &v
}