package services import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/repositories" "github.com/treytartt/honeydue-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) { // Task due 29 days from today's start of day should be "due_soon" // (within the 30-day threshold) now := time.Now().UTC() startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) dueDate := startOfToday.AddDate(0, 0, 29) // 29 days from start of today task := &models.Task{ NextDueDate: &dueDate, } column := responses.DetermineKanbanColumn(task, 30) assert.Equal(t, "due_soon_tasks", column, "Task due 29 days from today should be in due_soon (within 30-day threshold)") } 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 - set to 29 days from today (within 30-day threshold) task, _ = taskRepo.FindByID(task.ID) now := time.Now().UTC() startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) nextDue := startOfToday.AddDate(0, 0, 29) // 29 days from start of today (within threshold) task.NextDueDate = &nextDue db.Save(task) task, _ = taskRepo.FindByID(task.ID) // With day-based comparisons: // - Threshold = start of today + 30 days // - A task due on day 29 is Before(threshold), so it's "due_soon" // - A task due on day 30+ is NOT Before(threshold), so it's "upcoming" column := responses.DetermineKanbanColumn(task, 30) assert.Equal(t, "due_soon_tasks", column, "Monthly task within 30-day threshold should be due_soon") } 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) }