Files
honeyDueAPI/internal/services/task_categorization_test.go
Trey t 0cf64cfb0c Add performance optimizations and database indexes
Database Indexes (migrations 006-009):
- Add case-insensitive indexes for auth lookups (email, username)
- Add composite indexes for task kanban queries
- Add indexes for notification, document, and completion queries
- Add unique index for active share codes
- Remove redundant idx_share_code_active and idx_notification_user_sent

Repository Optimizations:
- Add FindResidenceIDsByUser() lightweight method (IDs only, no preloads)
- Optimize GetResidenceUsers() with single UNION query (was 2 queries)
- Optimize kanban completion preloads to minimal columns (id, task_id, completed_at)

Service Optimizations:
- Remove Category/Priority/Frequency preloads from task queries
- Remove summary calculations from CRUD responses (client calculates)
- Use lightweight FindResidenceIDsByUser() instead of full FindByUser()

These changes reduce database load and response times for common operations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 01:06:08 -06:00

1106 lines
33 KiB
Go

package services
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/testutil"
)
/*
================================================================================
TASK CATEGORIZATION LOGIC - COMPREHENSIVE TEST SUITE
================================================================================
This file contains the definitive tests for task categorization logic.
The categorization determines which kanban column a task appears in.
CORE CONCEPTS:
-------------
1. DueDate: The original due date set when task was created
2. NextDueDate: The "effective" due date used for categorization
- For new tasks: NextDueDate = DueDate
- For completed one-time tasks: NextDueDate = nil
- For completed recurring tasks: NextDueDate = CompletedAt + FrequencyDays
KANBAN COLUMNS (in priority order):
----------------------------------
1. CANCELLED: Task.IsCancelled = true
2. COMPLETED: NextDueDate = nil AND has completions (one-time task done)
3. IN_PROGRESS: InProgress = true
4. OVERDUE: NextDueDate < now
5. DUE_SOON: NextDueDate < now + daysThreshold (default 30)
6. UPCOMING: Everything else (NextDueDate >= threshold or no due date)
RECURRING TASK BEHAVIOR:
-----------------------
- Recurring tasks NEVER go to "Completed" column permanently
- After completion, NextDueDate is updated to CompletedAt + FrequencyDays
- They cycle through: Upcoming -> Due Soon -> Overdue -> (complete) -> Upcoming...
ONE-TIME TASK BEHAVIOR:
----------------------
- One-time tasks (frequency=Once or frequency=nil) go to "Completed" when done
- After completion, NextDueDate = nil signals permanent completion
- They stay in "Completed" column forever
================================================================================
*/
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
// ptr returns a pointer to the given value
func ptr[T any](v T) *T {
return &v
}
// daysFromNow returns a time.Time that is n days from now
func daysFromNow(n int) time.Time {
return time.Now().UTC().AddDate(0, 0, n)
}
// daysAgo returns a time.Time that is n days ago
func daysAgo(n int) time.Time {
return time.Now().UTC().AddDate(0, 0, -n)
}
// isTaskCompleted checks if a task is permanently completed (one-time task done).
// A task is completed when it has completions AND NextDueDate is nil.
func isTaskCompleted(task *models.Task) bool {
if len(task.Completions) == 0 {
return false
}
return task.NextDueDate == nil
}
// ============================================================================
// isTaskCompleted FUNCTION TESTS
// ============================================================================
func TestIsTaskCompleted_NoCompletions_ReturnsFalse(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
Completions: []models.TaskCompletion{},
}
assert.False(t, isTaskCompleted(task),
"Task with no completions should not be considered completed")
}
func TestIsTaskCompleted_HasCompletions_WithNextDueDate_ReturnsFalse(t *testing.T) {
// This is a RECURRING task - has completions but NextDueDate is set for next occurrence
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
Completions: []models.TaskCompletion{
{CompletedAt: daysAgo(5)},
},
}
assert.False(t, isTaskCompleted(task),
"Recurring task with NextDueDate should not be considered completed")
}
func TestIsTaskCompleted_HasCompletions_NilNextDueDate_ReturnsTrue(t *testing.T) {
// This is a completed ONE-TIME task - NextDueDate is nil
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{
{CompletedAt: daysAgo(5)},
},
}
assert.True(t, isTaskCompleted(task),
"One-time task with nil NextDueDate and completions should be considered completed")
}
func TestIsTaskCompleted_NoCompletions_NilNextDueDate_ReturnsFalse(t *testing.T) {
// Task with no due date and no completions - not completed
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{},
}
assert.False(t, isTaskCompleted(task),
"Task with nil NextDueDate but no completions should not be considered completed")
}
// ============================================================================
// GetButtonTypesForTask FUNCTION TESTS
// ============================================================================
func TestGetButtonTypesForTask_CancelledTask(t *testing.T) {
task := &models.Task{
IsCancelled: true,
NextDueDate: ptr(daysAgo(5)), // Even if overdue, cancelled takes precedence
}
buttons := GetButtonTypesForTask(task, 30)
assert.ElementsMatch(t, []string{"uncancel", "delete"}, buttons,
"Cancelled task should only have uncancel and delete buttons")
}
func TestGetButtonTypesForTask_CompletedOneTimeTask(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{
{CompletedAt: daysAgo(5)},
},
}
buttons := GetButtonTypesForTask(task, 30)
assert.Empty(t, buttons,
"Completed one-time task should have no buttons (read-only)")
}
func TestGetButtonTypesForTask_InProgressTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
InProgress: true,
}
buttons := GetButtonTypesForTask(task, 30)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel"}, buttons,
"In Progress task should not have mark_in_progress button")
}
func TestGetButtonTypesForTask_OverdueTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysAgo(5)),
}
buttons := GetButtonTypesForTask(task, 30)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
"Overdue task should have all action buttons")
}
func TestGetButtonTypesForTask_DueSoonTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(15)), // Within 30-day threshold
}
buttons := GetButtonTypesForTask(task, 30)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
"Due soon task should have all action buttons")
}
func TestGetButtonTypesForTask_UpcomingTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(45)), // Beyond 30-day threshold
}
buttons := GetButtonTypesForTask(task, 30)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
"Upcoming task should have all action buttons")
}
func TestGetButtonTypesForTask_TaskWithNoDueDate(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
DueDate: nil,
}
buttons := GetButtonTypesForTask(task, 30)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
"Task with no due date should have all action buttons")
}
// ============================================================================
// GetIOSCategoryForTask FUNCTION TESTS
// ============================================================================
func TestGetIOSCategoryForTask_CancelledTask(t *testing.T) {
task := &models.Task{IsCancelled: true}
category := GetIOSCategoryForTask(task)
assert.Equal(t, IOSCategoryTaskCancelled, category)
}
func TestGetIOSCategoryForTask_CompletedTask(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
}
category := GetIOSCategoryForTask(task)
assert.Equal(t, IOSCategoryTaskCompleted, category)
}
func TestGetIOSCategoryForTask_InProgressTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
InProgress: true,
}
category := GetIOSCategoryForTask(task)
assert.Equal(t, IOSCategoryTaskInProgress, category)
}
func TestGetIOSCategoryForTask_ActionableTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
}
category := GetIOSCategoryForTask(task)
assert.Equal(t, IOSCategoryTaskActionable, category)
}
// ============================================================================
// DetermineKanbanColumn FUNCTION TESTS
// ============================================================================
func TestDetermineKanbanColumn_CancelledTask(t *testing.T) {
task := &models.Task{
IsCancelled: true,
NextDueDate: ptr(daysAgo(5)), // Even overdue
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "cancelled_tasks", column,
"Cancelled takes precedence over everything")
}
func TestDetermineKanbanColumn_CompletedOneTimeTask(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "completed_tasks", column)
}
func TestDetermineKanbanColumn_InProgressTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysAgo(5)), // Even overdue
InProgress: true,
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "in_progress_tasks", column,
"In Progress takes precedence over date-based columns")
}
func TestDetermineKanbanColumn_OverdueTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysAgo(5)),
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "overdue_tasks", column)
}
func TestDetermineKanbanColumn_DueSoonTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(15)),
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column)
}
func TestDetermineKanbanColumn_UpcomingTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(45)),
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "upcoming_tasks", column)
}
func TestDetermineKanbanColumn_NoDueDate(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
DueDate: nil,
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "upcoming_tasks", column,
"Tasks with no due date go to upcoming")
}
func TestDetermineKanbanColumn_FallbackToDueDate(t *testing.T) {
// Task with DueDate set but NextDueDate nil (legacy data)
task := &models.Task{
NextDueDate: nil,
DueDate: ptr(daysAgo(5)),
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "overdue_tasks", column,
"Should fallback to DueDate when NextDueDate is nil")
}
// ============================================================================
// ONE-TIME TASK LIFECYCLE TESTS (Integration)
// ============================================================================
func TestOneTimeTask_Lifecycle_Creation(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
_ = repositories.NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Once" frequency
var onceFreq models.TaskFrequency
db.Where("name = ?", "Once").First(&onceFreq)
dueDate := daysFromNow(10)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "One-Time Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &onceFreq.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
// Reload with frequency
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
// Verify initial state
assert.NotNil(t, task.NextDueDate, "NextDueDate should be set on creation")
assert.Equal(t, task.DueDate.Unix(), task.NextDueDate.Unix(),
"NextDueDate should equal DueDate on creation")
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"Task due in 10 days should be in due_soon")
}
func TestOneTimeTask_Lifecycle_Completion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
_ = repositories.NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Once" frequency
var onceFreq models.TaskFrequency
db.Where("name = ?", "Once").First(&onceFreq)
dueDate := daysFromNow(10)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "One-Time Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &onceFreq.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
// Simulate completion (what task_service.CreateCompletion does)
completedAt := time.Now().UTC()
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: completedAt,
}
err = taskRepo.CreateCompletion(completion)
require.NoError(t, err)
// Reload task to get frequency
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
// Simulate the next_due_date update (from task_service.CreateCompletion)
// For one-time task: NextDueDate becomes nil
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
task.NextDueDate = nil
}
err = taskRepo.Update(task)
require.NoError(t, err)
// Reload and verify
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
assert.Nil(t, task.NextDueDate,
"NextDueDate should be nil after one-time task completion")
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "completed_tasks", column,
"Completed one-time task should be in completed_tasks")
}
// ============================================================================
// RECURRING TASK LIFECYCLE TESTS (Integration)
// ============================================================================
func TestRecurringTask_Lifecycle_Creation(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Weekly" frequency
var weeklyFreq models.TaskFrequency
db.Where("name = ?", "Weekly").First(&weeklyFreq)
dueDate := daysFromNow(3) // Due in 3 days
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Weekly Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &weeklyFreq.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
// Reload with frequency
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
assert.NotNil(t, task.NextDueDate)
assert.Equal(t, task.DueDate.Unix(), task.NextDueDate.Unix())
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column)
}
func TestRecurringTask_Lifecycle_FirstCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Weekly" frequency (7 days)
var weeklyFreq models.TaskFrequency
db.Where("name = ?", "Weekly").First(&weeklyFreq)
require.NotNil(t, weeklyFreq.Days)
dueDate := daysFromNow(3)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Weekly Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &weeklyFreq.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
// Complete the task
completedAt := time.Now().UTC()
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: completedAt,
}
err = taskRepo.CreateCompletion(completion)
require.NoError(t, err)
// Reload task
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
// Simulate the next_due_date update (from task_service.CreateCompletion)
// For recurring task: NextDueDate = CompletedAt + FrequencyDays
// Note: Frequency is no longer preloaded for performance, use the weeklyFreq we already have
nextDue := completedAt.AddDate(0, 0, *weeklyFreq.Days)
task.NextDueDate = &nextDue
err = taskRepo.Update(task)
require.NoError(t, err)
// Reload and verify
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
assert.NotNil(t, task.NextDueDate,
"NextDueDate should NOT be nil for recurring task")
// NextDueDate should be ~7 days from completion
expectedNextDue := completedAt.AddDate(0, 0, 7)
assert.WithinDuration(t, expectedNextDue, *task.NextDueDate, time.Hour,
"NextDueDate should be 7 days after completion")
// Task should NOT be in completed column
column := responses.DetermineKanbanColumn(task, 30)
assert.NotEqual(t, "completed_tasks", column,
"Recurring task should NOT be in completed_tasks after completion")
assert.Equal(t, "due_soon_tasks", column,
"Recurring task should be in due_soon (next due in 7 days)")
}
func TestRecurringTask_Lifecycle_MultipleCompletions(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Weekly" frequency
var weeklyFreq models.TaskFrequency
db.Where("name = ?", "Weekly").First(&weeklyFreq)
// Create task due 3 days ago (overdue)
dueDate := daysAgo(3)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Weekly Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &weeklyFreq.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
// Initially overdue
task, _ = taskRepo.FindByID(task.ID)
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "overdue_tasks", column, "Task should start as overdue")
// First completion
completedAt1 := time.Now().UTC()
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: completedAt1,
})
task, _ = taskRepo.FindByID(task.ID)
// Note: Frequency is no longer preloaded for performance, use weeklyFreq we already have
nextDue1 := completedAt1.AddDate(0, 0, *weeklyFreq.Days)
task.NextDueDate = &nextDue1
taskRepo.Update(task)
// After first completion, should be due_soon
task, _ = taskRepo.FindByID(task.ID)
column = responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"After completion, task should move to due_soon")
// Simulate waiting until overdue again
overdueNextDue := daysAgo(2)
task.NextDueDate = &overdueNextDue
taskRepo.Update(task)
task, _ = taskRepo.FindByID(task.ID)
column = responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "overdue_tasks", column,
"Task should be overdue again when NextDueDate passes")
// Second completion
completedAt2 := time.Now().UTC()
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: completedAt2,
})
task, _ = taskRepo.FindByID(task.ID)
// Note: Frequency is no longer preloaded for performance, use weeklyFreq we already have
nextDue2 := completedAt2.AddDate(0, 0, *weeklyFreq.Days)
task.NextDueDate = &nextDue2
taskRepo.Update(task)
// After second completion
task, _ = taskRepo.FindByID(task.ID)
column = responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"After second completion, task moves back to due_soon")
// Verify we have 2 completions but task is NOT completed
assert.Len(t, task.Completions, 2)
assert.NotNil(t, task.NextDueDate,
"NextDueDate should never be nil for recurring task")
}
func TestRecurringTask_NeverInCompletedColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Weekly" frequency
var weeklyFreq models.TaskFrequency
db.Where("name = ?", "Weekly").First(&weeklyFreq)
dueDate := daysFromNow(3)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Weekly Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &weeklyFreq.ID,
}
db.Create(task)
// Add 10 completions
for i := 0; i < 10; i++ {
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: daysAgo(i * 7), // One per week
})
}
// Update NextDueDate as if properly maintained
task, _ = taskRepo.FindByID(task.ID)
nextDue := daysFromNow(4)
task.NextDueDate = &nextDue
taskRepo.Update(task)
task, _ = taskRepo.FindByID(task.ID)
// Despite having 10 completions, should NOT be in completed column
assert.Len(t, task.Completions, 10)
assert.NotNil(t, task.NextDueDate)
column := responses.DetermineKanbanColumn(task, 30)
assert.NotEqual(t, "completed_tasks", column,
"Recurring task should NEVER be in completed_tasks regardless of completion count")
}
// ============================================================================
// KANBAN BOARD INTEGRATION TESTS
// ============================================================================
func TestKanbanBoard_OneTimeTaskCompletion_MovesToCompleted(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Once" frequency
var onceFreq models.TaskFrequency
db.Where("name = ?", "Once").First(&onceFreq)
dueDate := daysFromNow(10)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "One-Time Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &onceFreq.ID,
}
db.Create(task)
// Before completion - should be in due_soon
board, err := taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
dueSoonCount := 0
completedCount := 0
for _, col := range board.Columns {
if col.Name == "due_soon_tasks" {
dueSoonCount = col.Count
}
if col.Name == "completed_tasks" {
completedCount = col.Count
}
}
assert.Equal(t, 1, dueSoonCount, "Task should be in due_soon before completion")
assert.Equal(t, 0, completedCount, "Completed should be empty before completion")
// Complete the task
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
})
task.NextDueDate = nil // One-time task
db.Save(task)
// After completion - should be in completed
board, err = taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
for _, col := range board.Columns {
if col.Name == "due_soon_tasks" {
dueSoonCount = col.Count
}
if col.Name == "completed_tasks" {
completedCount = col.Count
}
}
assert.Equal(t, 0, dueSoonCount, "due_soon should be empty after completion")
assert.Equal(t, 1, completedCount, "Task should be in completed after completion")
}
func TestKanbanBoard_RecurringTaskCompletion_StaysActionable(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Weekly" frequency
var weeklyFreq models.TaskFrequency
db.Where("name = ?", "Weekly").First(&weeklyFreq)
dueDate := daysAgo(1) // Overdue by 1 day
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Weekly Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &weeklyFreq.ID,
}
db.Create(task)
// Before completion - should be overdue
board, err := taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
var overdueCount, completedCount, dueSoonCount int
for _, col := range board.Columns {
switch col.Name {
case "overdue_tasks":
overdueCount = col.Count
case "completed_tasks":
completedCount = col.Count
case "due_soon_tasks":
dueSoonCount = col.Count
}
}
assert.Equal(t, 1, overdueCount, "Task should be overdue before completion")
assert.Equal(t, 0, completedCount, "Completed should be empty")
// Complete the task
completedAt := time.Now().UTC()
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: completedAt,
})
// Update NextDueDate for recurring task
nextDue := completedAt.AddDate(0, 0, 7)
task.NextDueDate = &nextDue
db.Save(task)
// After completion - should be in due_soon, NOT completed
board, err = taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
for _, col := range board.Columns {
switch col.Name {
case "overdue_tasks":
overdueCount = col.Count
case "completed_tasks":
completedCount = col.Count
case "due_soon_tasks":
dueSoonCount = col.Count
}
}
assert.Equal(t, 0, overdueCount, "Should no longer be overdue")
assert.Equal(t, 0, completedCount, "Should NOT be in completed (recurring task)")
assert.Equal(t, 1, dueSoonCount, "Should be in due_soon (next occurrence)")
}
// ============================================================================
// EDGE CASE TESTS
// ============================================================================
func TestEdgeCase_TaskDueExactlyAtThreshold(t *testing.T) {
// Test with 29 days which is clearly within threshold (not at boundary)
// to avoid timing-dependent boundary behavior
task := &models.Task{
NextDueDate: ptr(daysFromNow(29)), // 29 days from now, within 30-day threshold
}
column := responses.DetermineKanbanColumn(task, 30)
// 29 days is clearly within the 30-day threshold, so should be "due_soon"
assert.Equal(t, "due_soon_tasks", column,
"Task due within threshold should be in due_soon")
// Also test that 31 days (clearly beyond threshold) is upcoming
taskBeyond := &models.Task{
NextDueDate: ptr(daysFromNow(31)), // 31 days from now, beyond 30-day threshold
}
columnBeyond := responses.DetermineKanbanColumn(taskBeyond, 30)
assert.Equal(t, "upcoming_tasks", columnBeyond,
"Task due beyond threshold should be upcoming")
}
func TestEdgeCase_TaskDueJustBeforeThreshold(t *testing.T) {
// 29 days and 23 hours from now
dueDate := time.Now().UTC().Add(29*24*time.Hour + 23*time.Hour)
task := &models.Task{
NextDueDate: &dueDate,
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"Task due just before threshold should be in due_soon")
}
func TestEdgeCase_TaskDueInPast_ButHasCompletionAfter(t *testing.T) {
// One-time task that was overdue but got completed late
// NextDueDate should be nil after completion
task := &models.Task{
DueDate: ptr(daysAgo(5)),
NextDueDate: nil, // Set to nil after completion
Completions: []models.TaskCompletion{
{CompletedAt: daysAgo(1)}, // Completed 4 days late
},
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "completed_tasks", column,
"Late-completed one-time task should be in completed")
}
func TestEdgeCase_CancelledAndOverdue(t *testing.T) {
task := &models.Task{
IsCancelled: true,
NextDueDate: ptr(daysAgo(10)),
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "cancelled_tasks", column,
"Cancelled takes precedence over overdue")
}
func TestEdgeCase_InProgressAndOverdue(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysAgo(5)),
InProgress: true,
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "in_progress_tasks", column,
"In Progress takes precedence over overdue")
}
func TestEdgeCase_MonthlyRecurringTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "Monthly" frequency (30 days)
var monthlyFreq models.TaskFrequency
db.Where("name = ?", "Monthly").First(&monthlyFreq)
require.NotNil(t, monthlyFreq.Days)
require.Equal(t, 30, *monthlyFreq.Days)
dueDate := daysAgo(2) // Overdue
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Monthly Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
FrequencyID: &monthlyFreq.ID,
}
db.Create(task)
// Complete the task
completedAt := time.Now().UTC()
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: completedAt,
})
// Update NextDueDate
task, _ = taskRepo.FindByID(task.ID)
nextDue := completedAt.AddDate(0, 0, 30) // 30 days from now
task.NextDueDate = &nextDue
db.Save(task)
task, _ = taskRepo.FindByID(task.ID)
// 30 days from now is at/within threshold boundary - due to time precision,
// a task at exactly the threshold boundary is considered "due_soon" not "upcoming"
// because the check is NextDueDate.Before(threshold) which includes boundary due to ms precision
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"Monthly task at 30-day boundary should be due_soon (at threshold)")
}
func TestEdgeCase_ZeroDayFrequency_TreatedAsOneTime(t *testing.T) {
// Some systems might have frequency.Days = 0 instead of nil for one-time
zeroDays := 0
task := &models.Task{
NextDueDate: nil,
Frequency: &models.TaskFrequency{Days: &zeroDays},
Completions: []models.TaskCompletion{
{CompletedAt: daysAgo(1)},
},
}
// Should be treated as completed (one-time task logic)
assert.True(t, isTaskCompleted(task),
"Task with 0-day frequency and nil NextDueDate should be completed")
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "completed_tasks", column)
}
// ============================================================================
// BUTTON TYPES CONSISTENCY TESTS
// ============================================================================
func TestButtonTypes_ConsistencyWithKanbanColumn(t *testing.T) {
tests := []struct {
name string
task *models.Task
expectedColumn string
expectedButtons []string
}{
{
name: "Cancelled task",
task: &models.Task{
IsCancelled: true,
},
expectedColumn: "cancelled_tasks",
expectedButtons: []string{"uncancel", "delete"},
},
{
name: "Completed one-time task",
task: &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
},
expectedColumn: "completed_tasks",
expectedButtons: []string{}, // Read-only
},
{
name: "In Progress task",
task: &models.Task{
NextDueDate: ptr(daysFromNow(10)),
InProgress: true,
},
expectedColumn: "in_progress_tasks",
expectedButtons: []string{"edit", "complete", "cancel"},
},
{
name: "Overdue task",
task: &models.Task{
NextDueDate: ptr(daysAgo(5)),
},
expectedColumn: "overdue_tasks",
expectedButtons: []string{"edit", "complete", "cancel", "mark_in_progress"},
},
{
name: "Due soon task",
task: &models.Task{
NextDueDate: ptr(daysFromNow(15)),
},
expectedColumn: "due_soon_tasks",
expectedButtons: []string{"edit", "complete", "cancel", "mark_in_progress"},
},
{
name: "Upcoming task",
task: &models.Task{
NextDueDate: ptr(daysFromNow(45)),
},
expectedColumn: "upcoming_tasks",
expectedButtons: []string{"edit", "complete", "cancel", "mark_in_progress"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
column := responses.DetermineKanbanColumn(tt.task, 30)
buttons := GetButtonTypesForTask(tt.task, 30)
assert.Equal(t, tt.expectedColumn, column, "Column mismatch")
assert.ElementsMatch(t, tt.expectedButtons, buttons, "Button types mismatch")
})
}
}
// ============================================================================
// PRIORITY ORDER TESTS
// ============================================================================
func TestPriorityOrder_CancelledBeatsEverything(t *testing.T) {
// Task that is cancelled, overdue, in progress, with completions
task := &models.Task{
IsCancelled: true,
NextDueDate: ptr(daysAgo(10)),
InProgress: true,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "cancelled_tasks", column)
}
func TestPriorityOrder_CompletedBeatsInProgress(t *testing.T) {
// One-time task with In Progress status but completed
task := &models.Task{
NextDueDate: nil,
InProgress: true,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "completed_tasks", column)
}
func TestPriorityOrder_InProgressBeatsDateBased(t *testing.T) {
// Overdue task that's in progress
task := &models.Task{
NextDueDate: ptr(daysAgo(10)),
InProgress: true,
}
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "in_progress_tasks", column)
}