package services import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/dto/requests" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/repositories" "github.com/treytartt/honeydue-api/internal/task/categorization" "github.com/treytartt/honeydue-api/internal/testutil" ) // ============================================================================= // STATE TRANSITION TESTS (TDD) // ============================================================================= func TestTaskService_CompleteToCancel_OneTimeTask(t *testing.T) { // Complete a one-time task (NextDueDate=nil after completion), then cancel it. // Verify IsCancelled=true and kanban column is "cancelled". db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Complete Then Cancel", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Complete the task completionReq := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Completed", } _, err = service.CreateCompletion(completionReq, user.ID, now) require.NoError(t, err) // Verify task is completed (NextDueDate=nil) var taskAfterComplete models.Task db.Preload("Completions").First(&taskAfterComplete, task.ID) assert.Nil(t, taskAfterComplete.NextDueDate, "One-time task NextDueDate should be nil after completion") assert.True(t, len(taskAfterComplete.Completions) > 0, "Task should have completions") // Verify kanban column is "completed" before cancelling column := categorization.DetermineKanbanColumnWithTime(&taskAfterComplete, 30, now) assert.Equal(t, "completed_tasks", column, "Completed one-time task should be in completed column") // Step 2: Cancel the completed task cancelResp, err := service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, cancelResp.Data.IsCancelled, "Task should be cancelled") // Reload and verify kanban column is "cancelled" var taskAfterCancel models.Task db.Preload("Completions").First(&taskAfterCancel, task.ID) column = categorization.DetermineKanbanColumnWithTime(&taskAfterCancel, 30, now) assert.Equal(t, "cancelled_tasks", column, "Cancelled task should be in cancelled column regardless of completion state") } func TestTaskService_CancelToComplete(t *testing.T) { // Cancel a task, then complete the cancelled task. // Verify completion is created and IsCancelled remains true. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Cancel Then Complete", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Cancel the task cancelResp, err := service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, cancelResp.Data.IsCancelled) // Step 2: Complete the cancelled task (service does not prevent this) completionReq := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Completed even though cancelled", } completionResp, err := service.CreateCompletion(completionReq, user.ID, now) require.NoError(t, err) assert.NotZero(t, completionResp.Data.ID, "Completion should be created") // Reload and verify state var reloaded models.Task db.Preload("Completions").First(&reloaded, task.ID) assert.True(t, reloaded.IsCancelled, "IsCancelled should remain true after completion") assert.Nil(t, reloaded.NextDueDate, "NextDueDate should be nil for completed one-time task") assert.True(t, len(reloaded.Completions) > 0, "Task should have a completion") // Kanban column should be "cancelled" because cancelled takes priority column := categorization.DetermineKanbanColumnWithTime(&reloaded, 30, now) assert.Equal(t, "cancelled_tasks", column, "Cancelled takes priority over completed in kanban") } func TestTaskService_ArchiveToComplete(t *testing.T) { // Archive a task, then complete the archived task. // Verify completion is created and IsArchived remains true. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Archive Then Complete", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Archive the task archiveResp, err := service.ArchiveTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, archiveResp.Data.IsArchived) // Step 2: Complete the archived task completionReq := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Completed even though archived", } completionResp, err := service.CreateCompletion(completionReq, user.ID, now) require.NoError(t, err) assert.NotZero(t, completionResp.Data.ID, "Completion should be created") // Reload and verify state var reloaded models.Task db.Preload("Completions").First(&reloaded, task.ID) assert.True(t, reloaded.IsArchived, "IsArchived should remain true after completion") assert.Nil(t, reloaded.NextDueDate, "NextDueDate should be nil for completed one-time task") assert.True(t, len(reloaded.Completions) > 0, "Task should have a completion") // Kanban column: archived maps to cancelled column (both are "inactive" states) column := categorization.DetermineKanbanColumnWithTime(&reloaded, 30, now) assert.Equal(t, "cancelled_tasks", column, "Archived task should show in cancelled column") } func TestTaskService_CompleteToArchive_OneTimeTask(t *testing.T) { // Complete a one-time task, then archive it. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Complete Then Archive", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Complete the task completionReq := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Done", } _, err = service.CreateCompletion(completionReq, user.ID, now) require.NoError(t, err) // Step 2: Archive the completed task archiveResp, err := service.ArchiveTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, archiveResp.Data.IsArchived, "Task should be archived") // Reload and verify var reloaded models.Task db.Preload("Completions").First(&reloaded, task.ID) assert.True(t, reloaded.IsArchived) assert.Nil(t, reloaded.NextDueDate, "NextDueDate should remain nil") assert.True(t, len(reloaded.Completions) > 0, "Completions should be preserved") // Kanban column: archived maps to cancelled column := categorization.DetermineKanbanColumnWithTime(&reloaded, 30, now) assert.Equal(t, "cancelled_tasks", column, "Archived task should be in cancelled column") } func TestTaskService_InProgressToCancelToUncancel(t *testing.T) { // Mark task in progress, cancel it, then uncancel it. // Verify InProgress is preserved through cancel/uncancel cycle. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "InProgress Cancel Uncancel", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Mark in progress inProgressResp, err := service.MarkInProgress(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, inProgressResp.Data.InProgress) // Verify kanban column is in_progress var taskAfterIP models.Task db.First(&taskAfterIP, task.ID) column := categorization.DetermineKanbanColumnWithTime(&taskAfterIP, 30, now) assert.Equal(t, "in_progress_tasks", column, "Task should be in in_progress column") // Step 2: Cancel the in-progress task cancelResp, err := service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, cancelResp.Data.IsCancelled) // Cancel only sets is_cancelled=true, it does NOT reset in_progress var taskAfterCancel models.Task db.First(&taskAfterCancel, task.ID) assert.True(t, taskAfterCancel.IsCancelled) assert.True(t, taskAfterCancel.InProgress, "InProgress should still be true after cancel (cancel only sets is_cancelled)") // Kanban column should be cancelled (highest priority) column = categorization.DetermineKanbanColumnWithTime(&taskAfterCancel, 30, now) assert.Equal(t, "cancelled_tasks", column, "Cancelled task should be in cancelled column") // Step 3: Uncancel the task uncancelResp, err := service.UncancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.False(t, uncancelResp.Data.IsCancelled) // InProgress should still be true after uncancel var taskAfterUncancel models.Task db.First(&taskAfterUncancel, task.ID) assert.False(t, taskAfterUncancel.IsCancelled) assert.True(t, taskAfterUncancel.InProgress, "InProgress should still be true after uncancel") // Task should return to in_progress kanban column column = categorization.DetermineKanbanColumnWithTime(&taskAfterUncancel, 30, now) assert.Equal(t, "in_progress_tasks", column, "Uncancelled in-progress task should return to in_progress column") } func TestTaskService_MultipleCancelUncancelCycles(t *testing.T) { // Cancel -> Uncancel -> Cancel -> Uncancel // Verify final state is uncancelled. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Multiple Cancel Uncancel", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Cycle 1: Cancel -> Uncancel _, err = service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) _, err = service.UncancelTask(task.ID, user.ID, now) require.NoError(t, err) // Cycle 2: Cancel -> Uncancel _, err = service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) uncancelResp, err := service.UncancelTask(task.ID, user.ID, now) require.NoError(t, err) // Verify final state assert.False(t, uncancelResp.Data.IsCancelled, "Task should be uncancelled after two cycles") // Reload from DB to confirm var reloaded models.Task db.First(&reloaded, task.ID) assert.False(t, reloaded.IsCancelled, "Database should confirm task is uncancelled") // Kanban column should be based on dates (due_soon since due date is within 30 days) column := categorization.DetermineKanbanColumnWithTime(&reloaded, 30, now) assert.Equal(t, "due_soon_tasks", column, "Uncancelled task with due date within 30 days should be due_soon") } func TestTaskService_CompleteToMarkInProgress_OneTimeTask(t *testing.T) { // Complete a one-time task (NextDueDate=nil), then mark it in progress. // InProgress is set to true but task is still "completed" per predicates (chain priority). db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Complete Then InProgress", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Complete the task (sets NextDueDate=nil, InProgress=false for one-time) completionReq := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Done", } _, err = service.CreateCompletion(completionReq, user.ID, now) require.NoError(t, err) // Verify completed state var taskAfterComplete models.Task db.Preload("Completions").First(&taskAfterComplete, task.ID) assert.Nil(t, taskAfterComplete.NextDueDate) // Step 2: Mark in progress ipResp, err := service.MarkInProgress(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, ipResp.Data.InProgress, "InProgress should be true") // Verify NextDueDate is still nil var taskAfterIP models.Task db.Preload("Completions").First(&taskAfterIP, task.ID) assert.Nil(t, taskAfterIP.NextDueDate, "NextDueDate should still be nil") assert.True(t, taskAfterIP.InProgress) // Kanban column: completed handler (priority 3) is checked before in_progress (priority 4) // So the task is still "completed" since NextDueDate=nil and has completions column := categorization.DetermineKanbanColumnWithTime(&taskAfterIP, 30, now) assert.Equal(t, "completed_tasks", column, "Completed takes priority over in_progress in the chain") } func TestTaskService_RecurringTaskStateCycle(t *testing.T) { // Full cycle for a recurring weekly task: // Create -> Mark in progress -> Complete (recalculates NextDueDate) -> // Mark in progress again -> Complete again (recalculates again) db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") var weeklyFrequency models.TaskFrequency db.Where("name = ?", "Weekly").First(&weeklyFrequency) dueDate := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Weekly Recurring Task", FrequencyID: &weeklyFrequency.ID, DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Step 1: Mark in progress ipResp, err := service.MarkInProgress(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, ipResp.Data.InProgress) // Verify kanban column var taskAfterIP models.Task db.First(&taskAfterIP, task.ID) column := categorization.DetermineKanbanColumnWithTime(&taskAfterIP, 30, now) assert.Equal(t, "in_progress_tasks", column) // Step 2: Complete (first cycle) firstCompletedAt := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) completionReq1 := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Week 1 done", CompletedAt: &firstCompletedAt, } _, err = service.CreateCompletion(completionReq1, user.ID, now) require.NoError(t, err) // Verify: InProgress reset to false, NextDueDate recalculated var taskAfterComplete1 models.Task db.First(&taskAfterComplete1, task.ID) assert.False(t, taskAfterComplete1.InProgress, "InProgress should be reset after completion") require.NotNil(t, taskAfterComplete1.NextDueDate, "Recurring task should have NextDueDate") expectedNextDue1 := firstCompletedAt.AddDate(0, 0, 7) // weekly = 7 days assert.Equal(t, expectedNextDue1.Year(), taskAfterComplete1.NextDueDate.Year()) assert.Equal(t, expectedNextDue1.Month(), taskAfterComplete1.NextDueDate.Month()) assert.Equal(t, expectedNextDue1.Day(), taskAfterComplete1.NextDueDate.Day()) // Kanban column should not be "completed" since NextDueDate is set for recurring column = categorization.DetermineKanbanColumnWithTime(&taskAfterComplete1, 30, now) assert.NotEqual(t, "completed_tasks", column, "Recurring task should not be in completed column") // Step 3: Mark in progress again for next cycle ipResp2, err := service.MarkInProgress(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, ipResp2.Data.InProgress) // Step 4: Complete (second cycle) secondCompletedAt := time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC) completionReq2 := &requests.CreateTaskCompletionRequest{ TaskID: task.ID, Notes: "Week 2 done", CompletedAt: &secondCompletedAt, } _, err = service.CreateCompletion(completionReq2, user.ID, now) require.NoError(t, err) // Verify: InProgress reset again, NextDueDate recalculated from second completion var taskAfterComplete2 models.Task db.First(&taskAfterComplete2, task.ID) assert.False(t, taskAfterComplete2.InProgress, "InProgress should be reset after second completion") require.NotNil(t, taskAfterComplete2.NextDueDate, "Recurring task should have NextDueDate after second completion") expectedNextDue2 := secondCompletedAt.AddDate(0, 0, 7) assert.Equal(t, expectedNextDue2.Year(), taskAfterComplete2.NextDueDate.Year()) assert.Equal(t, expectedNextDue2.Month(), taskAfterComplete2.NextDueDate.Month()) assert.Equal(t, expectedNextDue2.Day(), taskAfterComplete2.NextDueDate.Day()) } func TestTaskService_ArchiveToUnarchive(t *testing.T) { // Archive a task, then unarchive it. // Verify the task returns to the correct kanban column based on dates. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") // Create a task with due date 5 days from now (should be "due_soon") dueDate := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Archive Unarchive", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Verify initial kanban column column := categorization.DetermineKanbanColumnWithTime(task, 30, now) assert.Equal(t, "due_soon_tasks", column, "Task due in 5 days should be due_soon") // Step 1: Archive archiveResp, err := service.ArchiveTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, archiveResp.Data.IsArchived) var taskAfterArchive models.Task db.First(&taskAfterArchive, task.ID) column = categorization.DetermineKanbanColumnWithTime(&taskAfterArchive, 30, now) assert.Equal(t, "cancelled_tasks", column, "Archived task should be in cancelled column") // Step 2: Unarchive unarchiveResp, err := service.UnarchiveTask(task.ID, user.ID, now) require.NoError(t, err) assert.False(t, unarchiveResp.Data.IsArchived) var taskAfterUnarchive models.Task db.First(&taskAfterUnarchive, task.ID) assert.False(t, taskAfterUnarchive.IsArchived) // Task should return to its correct kanban column based on dates column = categorization.DetermineKanbanColumnWithTime(&taskAfterUnarchive, 30, now) assert.Equal(t, "due_soon_tasks", column, "Unarchived task should return to due_soon column based on dates") } // ============================================================================= // KANBAN COLUMN PRIORITY VERIFICATION TESTS // ============================================================================= func TestKanbanColumn_CancelledAlwaysTakesPriority(t *testing.T) { // Cancelled task always shows as "cancelled" regardless of other state flags. now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Cancelled + InProgress task1 := &models.Task{ IsCancelled: true, InProgress: true, } column := categorization.DetermineKanbanColumnWithTime(task1, 30, now) assert.Equal(t, "cancelled_tasks", column, "Cancelled+InProgress should be cancelled") // Cancelled + Completed (NextDueDate=nil with completions) task2 := &models.Task{ IsCancelled: true, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, } column = categorization.DetermineKanbanColumnWithTime(task2, 30, now) assert.Equal(t, "cancelled_tasks", column, "Cancelled+Completed should be cancelled") // Cancelled + Overdue overdueDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) task3 := &models.Task{ IsCancelled: true, NextDueDate: &overdueDate, } column = categorization.DetermineKanbanColumnWithTime(task3, 30, now) assert.Equal(t, "cancelled_tasks", column, "Cancelled+Overdue should be cancelled") } func TestKanbanColumn_ArchivedAlwaysShowsCancelled(t *testing.T) { // Archived task always shows as "cancelled" (archived maps to cancelled column). now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) // Archived + InProgress task1 := &models.Task{ IsArchived: true, InProgress: true, } column := categorization.DetermineKanbanColumnWithTime(task1, 30, now) assert.Equal(t, "cancelled_tasks", column, "Archived+InProgress should be cancelled") // Archived + Overdue date overdueDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) task2 := &models.Task{ IsArchived: true, NextDueDate: &overdueDate, } column = categorization.DetermineKanbanColumnWithTime(task2, 30, now) assert.Equal(t, "cancelled_tasks", column, "Archived+Overdue should be cancelled") } func TestKanbanColumn_CompletedTaskPriority(t *testing.T) { // Completed task (NextDueDate=nil + completions) shows as "completed". now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) task := &models.Task{ NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, IsCancelled: false, IsArchived: false, } column := categorization.DetermineKanbanColumnWithTime(task, 30, now) assert.Equal(t, "completed_tasks", column) } func TestKanbanColumn_InProgressTaskPriority(t *testing.T) { // InProgress task shows as "in_progress" (when not cancelled/archived/completed). now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) dueDate := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC) task := &models.Task{ InProgress: true, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, } column := categorization.DetermineKanbanColumnWithTime(task, 30, now) assert.Equal(t, "in_progress_tasks", column) } func TestKanbanColumn_OverdueTaskPriority(t *testing.T) { // Overdue task shows as "overdue". now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) overdueDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) task := &models.Task{ NextDueDate: &overdueDate, IsCancelled: false, IsArchived: false, InProgress: false, } column := categorization.DetermineKanbanColumnWithTime(task, 30, now) assert.Equal(t, "overdue_tasks", column) } func TestKanbanColumn_DueSoonTaskPriority(t *testing.T) { // Due soon task (within threshold) shows as "due_soon". now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) dueSoonDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC) task := &models.Task{ NextDueDate: &dueSoonDate, IsCancelled: false, IsArchived: false, InProgress: false, } column := categorization.DetermineKanbanColumnWithTime(task, 30, now) assert.Equal(t, "due_soon_tasks", column) } func TestKanbanColumn_UpcomingTaskDefault(t *testing.T) { // Default task (no special state, far-future due date) shows as "upcoming". now := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) farFuture := time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ NextDueDate: &farFuture, IsCancelled: false, IsArchived: false, InProgress: false, } column := categorization.DetermineKanbanColumnWithTime(task, 30, now) assert.Equal(t, "upcoming_tasks", column) // Also verify a task with no due date and no completions is upcoming taskNoDue := &models.Task{ NextDueDate: nil, DueDate: nil, IsCancelled: false, IsArchived: false, InProgress: false, } column = categorization.DetermineKanbanColumnWithTime(taskNoDue, 30, now) assert.Equal(t, "upcoming_tasks", column, "Task with no due date and no completions should be upcoming") } // ============================================================================= // OPTIMISTIC LOCKING TESTS // ============================================================================= func TestTaskService_OptimisticLocking_UpdateWithCorrectVersion(t *testing.T) { // Update task with correct version -> success, version incremented. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Version Test") // Verify initial version var initialTask models.Task db.First(&initialTask, task.ID) initialVersion := initialTask.Version // Update task via service newTitle := "Updated Title" req := &requests.UpdateTaskRequest{ Title: &newTitle, } now := time.Now().UTC() resp, err := service.UpdateTask(task.ID, user.ID, req, now) require.NoError(t, err) assert.Equal(t, "Updated Title", resp.Data.Title) // Verify version was incremented var updatedTask models.Task db.First(&updatedTask, task.ID) assert.Equal(t, initialVersion+1, updatedTask.Version, "Version should be incremented after successful update") } func TestTaskService_OptimisticLocking_UpdateWithStaleVersion(t *testing.T) { // Update task with stale version -> verify ErrVersionConflict returned. 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") task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Stale Version Test", IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) // Bump version in DB to simulate concurrent modification db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 999) // Try to update with stale version (task object still has version=1) task.Title = "Should Fail" err = taskRepo.Update(task) require.Error(t, err) assert.ErrorIs(t, err, repositories.ErrVersionConflict, "Should return ErrVersionConflict for stale version") } func TestTaskService_OptimisticLocking_CompleteWithStaleVersion(t *testing.T) { // Complete task with stale version -> verify conflict handled and completion rolled back. 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") dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Completion Conflict Test", DueDate: &dueDate, NextDueDate: &dueDate, IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) // Bump version in DB to simulate concurrent modification db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 999) // Simulate the transactional completion with stale task (version=1, DB has 999) task.NextDueDate = nil task.InProgress = false completion := &models.TaskCompletion{ TaskID: task.ID, CompletedByID: user.ID, CompletedAt: time.Now().UTC(), Notes: "Should be rolled back", } txErr := taskRepo.DB().Transaction(func(tx *gorm.DB) error { if err := taskRepo.CreateCompletionTx(tx, completion); err != nil { return err } if err := taskRepo.UpdateTx(tx, task); err != nil { return err } return nil }) require.Error(t, txErr, "Transaction should fail due to version conflict") assert.ErrorIs(t, txErr, repositories.ErrVersionConflict, "Error should be ErrVersionConflict") // Verify the completion was rolled back (transaction atomicity) var count int64 db.Model(&models.TaskCompletion{}).Where("task_id = ?", task.ID).Count(&count) assert.Equal(t, int64(0), count, "Completion should be rolled back when version conflict occurs") } func TestTaskService_OptimisticLocking_CancelWithStaleVersion(t *testing.T) { // Cancel task with stale version -> verify conflict error returned. 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") task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Cancel Conflict Test", IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) // Cancel with correct version -> success (version becomes 2) err = taskRepo.Cancel(task.ID, 1) require.NoError(t, err) // Uncancel with correct version 2 -> success (version becomes 3) err = taskRepo.Uncancel(task.ID, 2) require.NoError(t, err) // Try cancel with stale version 2 (current is 3) -> conflict err = taskRepo.Cancel(task.ID, 2) require.Error(t, err) assert.ErrorIs(t, err, repositories.ErrVersionConflict, "Cancel with stale version should return ErrVersionConflict") } func TestTaskService_OptimisticLocking_MarkInProgressWithStaleVersion(t *testing.T) { // MarkInProgress with stale version -> verify conflict. 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") task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "InProgress Conflict Test", IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) // Bump version to simulate concurrent modification db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 50) err = taskRepo.MarkInProgress(task.ID, 1) require.Error(t, err) assert.ErrorIs(t, err, repositories.ErrVersionConflict) } func TestTaskService_OptimisticLocking_ArchiveWithStaleVersion(t *testing.T) { // Archive with stale version -> verify conflict. 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") task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Archive Conflict Test", IsCancelled: false, IsArchived: false, Version: 1, } err := db.Create(task).Error require.NoError(t, err) db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 50) err = taskRepo.Archive(task.ID, 1) require.Error(t, err) assert.ErrorIs(t, err, repositories.ErrVersionConflict) } func TestTaskService_OptimisticLocking_ServiceCancelConflictAndRecovery(t *testing.T) { // Test the repo-level conflict detection, then verify service cancel succeeds // when the version is correct. db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) residenceRepo := repositories.NewResidenceRepository(db) service := NewTaskService(taskRepo, residenceRepo) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Service Conflict Test") now := time.Now().UTC() // Verify repo-level conflict detection with stale version db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 999) err := taskRepo.Cancel(task.ID, 1) require.Error(t, err) assert.ErrorIs(t, err, repositories.ErrVersionConflict) // Reset version and verify service cancel succeeds with correct version db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 1) cancelResp, err := service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, cancelResp.Data.IsCancelled, "Service cancel should succeed with correct version") }