From 4c9a818bd9efbd9e05abeed677fad36c797374ee Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 26 Mar 2026 17:36:50 -0500 Subject: [PATCH] =?UTF-8?q?Comprehensive=20TDD=20test=20suite=20for=20task?= =?UTF-8?q?=20logic=20=E2=80=94=20~80=20new=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Predicates (20 cases): IsRecurring, IsOneTime, IsDueSoon, HasCompletions, GetCompletionCount, IsUpcoming edge cases Task creation (10): NextDueDate initialization, all frequency types, past dates, all optional fields, access validation One-time completion (8): NextDueDate→nil, InProgress reset, notes/cost/rating, double completion, backdated completed_at Recurring completion (16): Daily/Weekly/BiWeekly/Monthly/Quarterly/ Yearly/Custom frequencies, late/early completion timing, multiple sequential completions, no-original-DueDate, CompletedFromColumn capture QuickComplete (5): one-time, recurring, widget notes, 404, 403 State transitions (10): Cancel→Complete, Archive→Complete, InProgress cycles, recurring full lifecycle, Archive→Unarchive column restore Kanban column priority (7): verify chain priority order for all columns Optimistic locking (7): correct/stale version, conflict on complete/ cancel/archive/mark-in-progress, rollback verification Deletion (5): single/multi/middle completion deletion, NextDueDate recalculation, InProgress restore behavior documented Edge cases (9): boundary dates, late/early recurring, nil/zero frequency days, custom intervals, version conflicts Handler validation (4): rating bounds, title/description length, custom interval validation All 679 tests pass. --- internal/handlers/task_handler_test.go | 156 ++ .../task_service_state_transition_test.go | 952 +++++++ internal/services/task_service_test.go | 2198 +++++++++++++++++ internal/task/predicates/predicates_test.go | 243 +- 4 files changed, 3546 insertions(+), 3 deletions(-) create mode 100644 internal/services/task_service_state_transition_test.go diff --git a/internal/handlers/task_handler_test.go b/internal/handlers/task_handler_test.go index bd7c353..9030967 100644 --- a/internal/handlers/task_handler_test.go +++ b/internal/handlers/task_handler_test.go @@ -840,3 +840,159 @@ func TestTaskHandler_JSONResponses(t *testing.T) { assert.IsType(t, []interface{}{}, response["columns"]) }) } + +// ============================================================================= +// Part 3: Handler-Level Edge Cases (TDD) +// ============================================================================= + +func TestTaskHandler_CreateCompletion_RatingValidation(t *testing.T) { + handler, e, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := e.Group("/api/task-completions") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateCompletion) + + tests := []struct { + name string + rating int + wantStatus int + }{ + {"rating_0_rejected", 0, http.StatusBadRequest}, + {"rating_negative1_rejected", -1, http.StatusBadRequest}, + {"rating_1_accepted", 1, http.StatusCreated}, + {"rating_5_accepted", 5, http.StatusCreated}, + {"rating_6_rejected", 6, http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a fresh task for each accepted rating (otherwise it's completed) + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Rate Me "+tt.name) + completedAt := time.Now().UTC() + rating := tt.rating + req := requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + CompletedAt: &completedAt, + Rating: &rating, + } + + w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token") + testutil.AssertStatusCode(t, w, tt.wantStatus) + }) + } +} + +func TestTaskHandler_CreateTask_TitleBoundary(t *testing.T) { + handler, e, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := e.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateTask) + + t.Run("title_exactly_200_chars_accepted", func(t *testing.T) { + title200 := "" + for i := 0; i < 200; i++ { + title200 += "A" + } + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: title200, + } + + w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") + testutil.AssertStatusCode(t, w, http.StatusCreated) + }) + + t.Run("title_201_chars_rejected", func(t *testing.T) { + title201 := "" + for i := 0; i < 201; i++ { + title201 += "A" + } + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: title201, + } + + w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) +} + +func TestTaskHandler_CreateTask_DescriptionBoundary(t *testing.T) { + handler, e, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := e.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateTask) + + t.Run("description_exactly_10000_chars_accepted", func(t *testing.T) { + desc10000 := "" + for i := 0; i < 10000; i++ { + desc10000 += "B" + } + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Long Desc Task", + Description: desc10000, + } + + w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") + testutil.AssertStatusCode(t, w, http.StatusCreated) + }) +} + +func TestTaskHandler_CreateTask_CustomIntervalDaysValidation(t *testing.T) { + handler, e, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := e.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateTask) + + t.Run("custom_interval_days_0_rejected", func(t *testing.T) { + // Validation tag: min=1, so 0 should be rejected + interval := 0 + req := map[string]interface{}{ + "residence_id": residence.ID, + "title": "Custom Interval Zero", + "custom_interval_days": interval, + } + + w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) + + t.Run("custom_interval_days_negative1_rejected", func(t *testing.T) { + interval := -1 + req := map[string]interface{}{ + "residence_id": residence.ID, + "title": "Custom Interval Negative", + "custom_interval_days": interval, + } + + w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) + + t.Run("custom_interval_days_1_accepted", func(t *testing.T) { + interval := 1 + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Custom Interval One", + CustomIntervalDays: &interval, + } + + w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") + testutil.AssertStatusCode(t, w, http.StatusCreated) + }) +} diff --git a/internal/services/task_service_state_transition_test.go b/internal/services/task_service_state_transition_test.go new file mode 100644 index 0000000..85c9a13 --- /dev/null +++ b/internal/services/task_service_state_transition_test.go @@ -0,0 +1,952 @@ +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") +} diff --git a/internal/services/task_service_test.go b/internal/services/task_service_test.go index 1764e16..f38892e 100644 --- a/internal/services/task_service_test.go +++ b/internal/services/task_service_test.go @@ -848,3 +848,2201 @@ func TestTaskService_SharedUserAccess(t *testing.T) { _, err = service.CreateTask(req, sharedUser.ID, now) require.NoError(t, err) } + +// ============================================================================= +// Task Creation: NextDueDate Initialization +// ============================================================================= + +func TestTaskService_CreateTask_NextDueDateEqualsDueDate(t *testing.T) { + 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 := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, 14).UTC()} + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Task with due date", + DueDate: &dueDate, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + require.NotNil(t, resp.Data.DueDate, "DueDate should be set") + require.NotNil(t, resp.Data.NextDueDate, "NextDueDate should be initialized") + assert.Equal(t, resp.Data.DueDate.Format("2006-01-02"), resp.Data.NextDueDate.Format("2006-01-02"), + "NextDueDate should equal DueDate on creation") +} + +func TestTaskService_CreateTask_NoDueDate_NextDueDateNil(t *testing.T) { + 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") + + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Task without due date", + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + assert.Nil(t, resp.Data.DueDate, "DueDate should be nil") + assert.Nil(t, resp.Data.NextDueDate, "NextDueDate should be nil when no DueDate") +} + +// ============================================================================= +// Task Creation: Recurring Tasks +// ============================================================================= + +func TestTaskService_CreateTask_WithWeeklyFrequency(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, 7).UTC()} + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Weekly cleaning", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + assert.NotNil(t, resp.Data.FrequencyID, "FrequencyID should be saved") + assert.Equal(t, weeklyFreq.ID, *resp.Data.FrequencyID, "FrequencyID should match Weekly") +} + +func TestTaskService_CreateTask_WithCustomFrequencyAndInterval(t *testing.T) { + 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 custom frequency + days14 := 14 + customFreq := &models.TaskFrequency{Name: "Custom", Days: &days14, DisplayOrder: 10} + db.Create(customFreq) + + customDays := 14 + dueDate := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, 7).UTC()} + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Bi-weekly inspection", + FrequencyID: &customFreq.ID, + CustomIntervalDays: &customDays, + DueDate: &dueDate, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + assert.NotNil(t, resp.Data.FrequencyID, "FrequencyID should be saved") + assert.NotNil(t, resp.Data.CustomIntervalDays, "CustomIntervalDays should be saved") + assert.Equal(t, 14, *resp.Data.CustomIntervalDays, "CustomIntervalDays should be 14") +} + +func TestTaskService_CreateTask_FrequencyWithoutDueDate(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Recurring no due date", + FrequencyID: &weeklyFreq.ID, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + assert.NotNil(t, resp.Data.FrequencyID, "FrequencyID should be saved") + assert.Nil(t, resp.Data.NextDueDate, "NextDueDate should be nil when no DueDate") +} + +// ============================================================================= +// Task Creation: Edge Cases +// ============================================================================= + +func TestTaskService_CreateTask_WithPastDueDate(t *testing.T) { + 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") + + pastDate := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, -30).UTC()} + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Overdue from start", + DueDate: &pastDate, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err, "Creating task with past due date should not error") + require.NotNil(t, resp.Data.DueDate, "DueDate should be set") + assert.True(t, resp.Data.DueDate.Before(time.Now()), "DueDate should be in the past") +} + +func TestTaskService_CreateTask_WithInProgressTrue(t *testing.T) { + 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") + + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Already started", + InProgress: true, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + assert.True(t, resp.Data.InProgress, "InProgress should be true in response") + + // Verify in database + var reloaded models.Task + db.First(&reloaded, resp.Data.ID) + assert.True(t, reloaded.InProgress, "InProgress should be true in DB") +} + +func TestTaskService_CreateTask_AllOptionalFields(t *testing.T) { + 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") + assignee := testutil.CreateTestUser(t, db, "assignee", "assignee@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + // Add assignee to residence so they have access + residenceRepo.AddUser(residence.ID, assignee.ID) + + contractor := testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Joe's Plumbing") + + var category models.TaskCategory + var priority models.TaskPriority + var weeklyFreq models.TaskFrequency + db.First(&category) + db.First(&priority) + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, 7).UTC()} + cost := decimal.NewFromFloat(250.00) + + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Full task with all fields", + Description: "A comprehensive task", + CategoryID: &category.ID, + PriorityID: &priority.ID, + FrequencyID: &weeklyFreq.ID, + InProgress: true, + AssignedToID: &assignee.ID, + DueDate: &dueDate, + EstimatedCost: &cost, + ContractorID: &contractor.ID, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err) + + assert.Equal(t, "Full task with all fields", resp.Data.Title) + assert.Equal(t, "A comprehensive task", resp.Data.Description) + assert.NotNil(t, resp.Data.CategoryID, "CategoryID should be set") + assert.Equal(t, category.ID, *resp.Data.CategoryID) + assert.NotNil(t, resp.Data.PriorityID, "PriorityID should be set") + assert.Equal(t, priority.ID, *resp.Data.PriorityID) + assert.NotNil(t, resp.Data.FrequencyID, "FrequencyID should be set") + assert.Equal(t, weeklyFreq.ID, *resp.Data.FrequencyID) + assert.True(t, resp.Data.InProgress, "InProgress should be true") + assert.NotNil(t, resp.Data.AssignedToID, "AssignedToID should be set") + assert.Equal(t, assignee.ID, *resp.Data.AssignedToID) + assert.NotNil(t, resp.Data.DueDate, "DueDate should be set") + assert.NotNil(t, resp.Data.EstimatedCost, "EstimatedCost should be set") + assert.NotNil(t, resp.Data.ContractorID, "ContractorID should be set") + assert.Equal(t, contractor.ID, *resp.Data.ContractorID) +} + +func TestTaskService_CreateTask_CustomIntervalDaysWithoutFrequency(t *testing.T) { + 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") + + customDays := 10 + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Custom interval without frequency", + CustomIntervalDays: &customDays, + } + + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) + require.NoError(t, err, "Should save even without FrequencyID") + assert.NotNil(t, resp.Data.CustomIntervalDays, "CustomIntervalDays should be saved") + assert.Equal(t, 10, *resp.Data.CustomIntervalDays) + assert.Nil(t, resp.Data.FrequencyID, "FrequencyID should be nil") +} + +// ============================================================================= +// Task Creation: Validation (service-level) +// ============================================================================= + +func TestTaskService_CreateTask_InvalidResidenceAccess(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + taskRepo := repositories.NewTaskRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + service := NewTaskService(taskRepo, residenceRepo) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + stranger := testutil.CreateTestUser(t, db, "stranger", "stranger@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Owner's House") + + req := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Should be denied", + } + + now := time.Now().UTC() + _, err := service.CreateTask(req, stranger.ID, now) + testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied") +} + +func TestTaskService_CreateTask_NonExistentResidence(t *testing.T) { + 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") + + req := &requests.CreateTaskRequest{ + ResidenceID: 99999, + Title: "Nowhere to live", + } + + now := time.Now().UTC() + _, err := service.CreateTask(req, user.ID, now) + require.Error(t, err, "Should error for non-existent residence") + // Should return forbidden since user has no access to non-existent residence + testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied") +} + +// ============================================================================= +// Part 1: Completion Deletion Tests (TDD) +// ============================================================================= + +func TestTaskService_DeleteCompletion_OneTime_RestoresAndExitsKanbanCompleted(t *testing.T) { + // Verifies: deleting the only completion on a one-time task restores NextDueDate + // to original DueDate, and the task is no longer in the "completed" kanban state. + 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") + + originalDueDate := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "One-time KanbanCheck", + DueDate: &originalDueDate, + NextDueDate: &originalDueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + // Complete the task (sets NextDueDate to nil) + now := time.Now().UTC() + completionResp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + Notes: "Done", + }, user.ID, now) + require.NoError(t, err) + + // Confirm completed state: NextDueDate nil, has completion + var taskAfterComplete models.Task + db.Preload("Completions").First(&taskAfterComplete, task.ID) + assert.Nil(t, taskAfterComplete.NextDueDate, "NextDueDate should be nil after one-time completion") + assert.True(t, len(taskAfterComplete.Completions) > 0, "Should have completion") + // IsCompleted: NextDueDate == nil && HasCompletions + assert.True(t, taskAfterComplete.NextDueDate == nil && len(taskAfterComplete.Completions) > 0, + "Task should be in completed state") + + // Delete the completion + _, err = service.DeleteCompletion(completionResp.Data.ID, user.ID) + require.NoError(t, err) + + // Verify NextDueDate restored to original DueDate + var taskAfterDelete models.Task + db.Preload("Completions").First(&taskAfterDelete, task.ID) + require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be restored") + assert.Equal(t, originalDueDate.Year(), taskAfterDelete.NextDueDate.Year()) + assert.Equal(t, originalDueDate.Month(), taskAfterDelete.NextDueDate.Month()) + assert.Equal(t, originalDueDate.Day(), taskAfterDelete.NextDueDate.Day()) + + // Verify no completions remain + assert.Equal(t, 0, len(taskAfterDelete.Completions), "No completions should remain") + + // Verify NOT in completed state anymore + assert.False(t, taskAfterDelete.NextDueDate == nil && len(taskAfterDelete.Completions) > 0, + "Task should NOT be in completed state after deleting its only completion") +} + +func TestTaskService_DeleteCompletion_Recurring_RestoresToOriginalDueDate(t *testing.T) { + // Verifies: deleting the only completion on a recurring task restores NextDueDate + // to the original DueDate. + 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) + + originalDueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Recurring Restore Test", + FrequencyID: &weeklyFrequency.ID, + DueDate: &originalDueDate, + NextDueDate: &originalDueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + // Complete on Apr 3 + completedAt := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + completionResp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Weekly done", CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + // Confirm NextDueDate advanced to Apr 10 (Apr 3 + 7) + var taskAfterComplete models.Task + db.First(&taskAfterComplete, task.ID) + require.NotNil(t, taskAfterComplete.NextDueDate) + assert.Equal(t, 10, taskAfterComplete.NextDueDate.Day()) + + // Delete the completion + _, err = service.DeleteCompletion(completionResp.Data.ID, user.ID) + require.NoError(t, err) + + // Verify NextDueDate restored to original DueDate (Apr 1) + var taskAfterDelete models.Task + db.First(&taskAfterDelete, task.ID) + require.NotNil(t, taskAfterDelete.NextDueDate) + assert.Equal(t, originalDueDate.Year(), taskAfterDelete.NextDueDate.Year()) + assert.Equal(t, originalDueDate.Month(), taskAfterDelete.NextDueDate.Month()) + assert.Equal(t, originalDueDate.Day(), taskAfterDelete.NextDueDate.Day()) +} + +func TestTaskService_DeleteCompletion_MultipleCompletions_SequentialDeletion(t *testing.T) { + // Verifies: deleting completions one-by-one from newest to oldest on a recurring task + // properly recalculates NextDueDate at each step. + 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) + + originalDueDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "3-completion Task", + FrequencyID: &weeklyFrequency.ID, + DueDate: &originalDueDate, + NextDueDate: &originalDueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + + // Completion 1: Jan 5 + c1At := time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC) + c1Resp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Completion 1", CompletedAt: &c1At, + }, user.ID, now) + require.NoError(t, err) + + // Completion 2: Jan 12 + c2At := time.Date(2026, 1, 12, 10, 0, 0, 0, time.UTC) + c2Resp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Completion 2", CompletedAt: &c2At, + }, user.ID, now) + require.NoError(t, err) + + // Completion 3: Jan 19 + c3At := time.Date(2026, 1, 19, 10, 0, 0, 0, time.UTC) + c3Resp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Completion 3", CompletedAt: &c3At, + }, user.ID, now) + require.NoError(t, err) + + // After 3 completions: NextDueDate = Jan 19 + 7 = Jan 26 + var taskAfter3 models.Task + db.First(&taskAfter3, task.ID) + require.NotNil(t, taskAfter3.NextDueDate) + assert.Equal(t, 26, taskAfter3.NextDueDate.Day()) + + // Delete completion 3 (latest) -> recalc from completion 2: Jan 12 + 7 = Jan 19 + _, err = service.DeleteCompletion(c3Resp.Data.ID, user.ID) + require.NoError(t, err) + var taskAfterDel3 models.Task + db.First(&taskAfterDel3, task.ID) + require.NotNil(t, taskAfterDel3.NextDueDate) + assert.Equal(t, 19, taskAfterDel3.NextDueDate.Day(), + "NextDueDate should be Jan 19 (completion 2: Jan 12 + 7)") + + // Delete completion 2 (now latest) -> recalc from completion 1: Jan 5 + 7 = Jan 12 + _, err = service.DeleteCompletion(c2Resp.Data.ID, user.ID) + require.NoError(t, err) + var taskAfterDel2 models.Task + db.First(&taskAfterDel2, task.ID) + require.NotNil(t, taskAfterDel2.NextDueDate) + assert.Equal(t, 12, taskAfterDel2.NextDueDate.Day(), + "NextDueDate should be Jan 12 (completion 1: Jan 5 + 7)") + + // Delete completion 1 (last remaining) -> restore to original DueDate: Jan 1 + _, err = service.DeleteCompletion(c1Resp.Data.ID, user.ID) + require.NoError(t, err) + var taskAfterDel1 models.Task + db.First(&taskAfterDel1, task.ID) + require.NotNil(t, taskAfterDel1.NextDueDate) + assert.Equal(t, 1, taskAfterDel1.NextDueDate.Day(), + "NextDueDate should be restored to original DueDate Jan 1") + assert.Equal(t, time.January, taskAfterDel1.NextDueDate.Month()) +} + +func TestTaskService_DeleteCompletion_MiddleCompletion_KeepsLatest(t *testing.T) { + // Verifies: deleting the middle (2nd) completion leaves NextDueDate based on the latest (3rd). + 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) + + originalDueDate := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Middle Deletion Task", + FrequencyID: &weeklyFrequency.ID, + DueDate: &originalDueDate, + NextDueDate: &originalDueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + + // Create 3 completions + c1At := time.Date(2026, 2, 3, 10, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "First", CompletedAt: &c1At, + }, user.ID, now) + require.NoError(t, err) + + c2At := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) + c2Resp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Second", CompletedAt: &c2At, + }, user.ID, now) + require.NoError(t, err) + + c3At := time.Date(2026, 2, 17, 10, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Third", CompletedAt: &c3At, + }, user.ID, now) + require.NoError(t, err) + + // After 3 completions: NextDueDate = Feb 17 + 7 = Feb 24 + var taskBefore models.Task + db.First(&taskBefore, task.ID) + require.NotNil(t, taskBefore.NextDueDate) + assert.Equal(t, 24, taskBefore.NextDueDate.Day()) + + // Delete the MIDDLE completion (2nd) + _, err = service.DeleteCompletion(c2Resp.Data.ID, user.ID) + require.NoError(t, err) + + // NextDueDate should still be based on the latest (3rd): Feb 17 + 7 = Feb 24 + var taskAfter models.Task + db.First(&taskAfter, task.ID) + require.NotNil(t, taskAfter.NextDueDate) + assert.Equal(t, 24, taskAfter.NextDueDate.Day(), + "Deleting middle completion should not change NextDueDate when latest still exists") +} + +func TestTaskService_DeleteCompletion_DoesNotRestoreInProgress(t *testing.T) { + // Documents behavior: DeleteCompletion does NOT restore InProgress. + // It only recalculates NextDueDate. + 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, 5, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "InProgress Task", + InProgress: true, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + // Complete it (sets InProgress = false) + now := time.Now().UTC() + completionResp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Completed", + }, user.ID, now) + require.NoError(t, err) + + var taskAfterComplete models.Task + db.First(&taskAfterComplete, task.ID) + assert.False(t, taskAfterComplete.InProgress, "InProgress should be false after completion") + + // Delete the completion + _, err = service.DeleteCompletion(completionResp.Data.ID, user.ID) + require.NoError(t, err) + + // InProgress is NOT restored by DeleteCompletion + var taskAfterDelete models.Task + db.First(&taskAfterDelete, task.ID) + assert.False(t, taskAfterDelete.InProgress, + "DeleteCompletion does not restore InProgress; it only recalculates NextDueDate") +} + +// ============================================================================= +// Part 2: Edge Case Tests (TDD) +// ============================================================================= + +func TestTaskService_TaskWithNoDates(t *testing.T) { + // Task with no DueDate, no frequency: upcoming before completion, completed after. + 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") + + createReq := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "No Date Task", + } + now := time.Now().UTC() + createResp, err := service.CreateTask(createReq, user.ID, now) + require.NoError(t, err) + + // Verify dates are nil + assert.Nil(t, createResp.Data.DueDate, "DueDate should be nil") + assert.Nil(t, createResp.Data.NextDueDate, "NextDueDate should be nil") + + // Kanban: upcoming (no date = upcoming for uncompleted tasks) + assert.Equal(t, "upcoming_tasks", createResp.Data.KanbanColumn, + "Task with no due date should be in upcoming column") + + // Complete it + completionResp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: createResp.Data.ID, Notes: "Done", + }, user.ID, now) + require.NoError(t, err) + assert.NotZero(t, completionResp.Data.ID) + + // Verify NextDueDate remains nil (one-time, no frequency) + var taskAfterComplete models.Task + db.Preload("Completions").First(&taskAfterComplete, createResp.Data.ID) + assert.Nil(t, taskAfterComplete.NextDueDate, "NextDueDate remains nil for dateless one-time task") + assert.True(t, len(taskAfterComplete.Completions) > 0, "Should have completion") + + // Now in completed state: NextDueDate==nil && HasCompletions + assert.True(t, taskAfterComplete.NextDueDate == nil && len(taskAfterComplete.Completions) > 0, + "Task should be in completed state") +} + +func TestTaskService_TaskDueExactlyToday_Boundary(t *testing.T) { + // Task due today is NOT overdue (today is not "past"). + 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") + + today := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Due Today Task", + DueDate: &today, + NextDueDate: &today, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + // NOT overdue + assert.False(t, task.IsOverdueAt(today), "Task due today should NOT be overdue") + + // Complete it + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Done today", + }, user.ID, today) + require.NoError(t, err) + + // Verify transition: NextDueDate nil (one-time) + var taskAfter models.Task + db.First(&taskAfter, task.ID) + assert.Nil(t, taskAfter.NextDueDate, "One-time task NextDueDate should be nil after completion") +} + +func TestTaskService_TaskDueYesterday_IsOverdue(t *testing.T) { + 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") + + today := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + yesterday := today.AddDate(0, 0, -1) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Due Yesterday Task", + DueDate: &yesterday, + NextDueDate: &yesterday, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + assert.True(t, task.IsOverdueAt(today), "Task due yesterday should be overdue") + + // Complete it + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Done late", + }, user.ID, today) + require.NoError(t, err) + + // Completed task is not overdue + var taskAfter models.Task + db.Preload("Completions").First(&taskAfter, task.ID) + assert.Nil(t, taskAfter.NextDueDate) + assert.True(t, len(taskAfter.Completions) > 0) + assert.False(t, taskAfter.IsOverdueAt(today), "Completed task should not be overdue") +} + +func TestTaskService_TaskVeryFarFuture(t *testing.T) { + // Task due in 2099 is in "upcoming" column. + 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") + + farFuture := time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC) + createReq := &requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Far Future Task", + DueDate: &requests.FlexibleDate{Time: farFuture}, + } + now := time.Now().UTC() + resp, err := service.CreateTask(createReq, user.ID, now) + require.NoError(t, err) + + assert.Equal(t, "upcoming_tasks", resp.Data.KanbanColumn, + "Task due in 2099 should be in upcoming column") +} + +func TestTaskService_RecurringTask_CompletedLate(t *testing.T) { + // Weekly task due Dec 10, completed Dec 15 -> NextDueDate = Dec 22 (CompletedAt + 7) + 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, 12, 10, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Late Completion Task", + FrequencyID: &weeklyFrequency.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 12, 15, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Late", CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + // NextDueDate = Dec 15 + 7 = Dec 22, NOT Dec 10 + 7 = Dec 17 + assert.Equal(t, 22, reloaded.NextDueDate.Day(), + "NextDueDate should be CompletedAt+7 (Dec 22), not DueDate+7 (Dec 17)") +} + +func TestTaskService_RecurringTask_CompletedEarly(t *testing.T) { + // Weekly task due Dec 17, completed Dec 14 -> NextDueDate = Dec 21 (CompletedAt + 7) + 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, 12, 17, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Early Completion Task", + FrequencyID: &weeklyFrequency.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 12, 14, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Early", CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + assert.Equal(t, 21, reloaded.NextDueDate.Day(), + "NextDueDate should be CompletedAt+7 (Dec 21)") + assert.Equal(t, time.December, reloaded.NextDueDate.Month()) +} + +func TestTaskService_CustomIntervalEdgeCases(t *testing.T) { + 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 "Custom" frequency (Days=0, overridden by task's CustomIntervalDays) + customDays := 0 + customFrequency := &models.TaskFrequency{ + Name: "Custom", + Days: &customDays, + DisplayOrder: 10, + } + err := db.Create(customFrequency).Error + require.NoError(t, err) + + t.Run("CustomIntervalDays=1_daily", func(t *testing.T) { + interval := 1 + dueDate := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Daily Custom Task", + FrequencyID: &customFrequency.ID, + CustomIntervalDays: &interval, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + assert.Equal(t, 2, reloaded.NextDueDate.Day(), "NextDueDate = Jun 1 + 1 = Jun 2") + }) + + t.Run("CustomIntervalDays=365_yearly", func(t *testing.T) { + interval := 365 + dueDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Yearly Custom Task", + FrequencyID: &customFrequency.ID, + CustomIntervalDays: &interval, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + assert.Equal(t, 2027, reloaded.NextDueDate.Year(), "NextDueDate year should be 2027") + assert.Equal(t, time.January, reloaded.NextDueDate.Month()) + assert.Equal(t, 1, reloaded.NextDueDate.Day()) + }) +} + +func TestTaskService_FrequencyWithNilOrZeroDays(t *testing.T) { + // Frequency with Days=nil or Days=0 is treated as one-time on completion. + 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") + + t.Run("frequency_with_nil_days", func(t *testing.T) { + var onceFrequency models.TaskFrequency + db.Where("name = ?", "Once").First(&onceFrequency) + require.Nil(t, onceFrequency.Days, "Once frequency should have nil Days") + + dueDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Once Frequency Task", + FrequencyID: &onceFrequency.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Done once", + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + assert.Nil(t, reloaded.NextDueDate, + "Frequency with Days=nil should be treated as one-time: NextDueDate=nil") + }) + + t.Run("frequency_with_zero_days", func(t *testing.T) { + zeroDays := 0 + zeroFreq := &models.TaskFrequency{ + Name: "ZeroTest", Days: &zeroDays, DisplayOrder: 99, + } + err := db.Create(zeroFreq).Error + require.NoError(t, err) + + dueDate := time.Date(2026, 7, 15, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Zero Days Frequency Task", + FrequencyID: &zeroFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err = db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Done zero", + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + assert.Nil(t, reloaded.NextDueDate, + "Frequency with Days=0 should be treated as one-time: NextDueDate=nil") + }) +} + +func TestTaskService_VersionConflict(t *testing.T) { + // Verifies: UpdateTx / Update checks version and returns ErrVersionConflict on stale version. + 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, "Conflict Task") + + // First update succeeds + newTitle := "Updated Once" + now := time.Now().UTC() + resp, err := service.UpdateTask(task.ID, user.ID, &requests.UpdateTaskRequest{ + Title: &newTitle, + }, now) + require.NoError(t, err) + assert.Equal(t, "Updated Once", resp.Data.Title) + + // Simulate stale version: read current, then bump version in DB + staleTask := &models.Task{} + db.First(staleTask, task.ID) + currentVersion := staleTask.Version + + db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", currentVersion+10) + + // Try update with stale version -> ErrVersionConflict + staleTask.Title = "Should Fail" + err = taskRepo.Update(staleTask) + assert.ErrorIs(t, err, repositories.ErrVersionConflict, + "Update with stale version should return ErrVersionConflict") +} + +// ============================================================================= +// Part 1: One-Time Task Completion Tests (TDD) +// ============================================================================= + +// seedAllFrequencies creates frequency records beyond what SeedLookupData provides +// (Once, Weekly, Monthly). Adds Daily, Bi-Weekly, Quarterly, Yearly, Custom. +func seedAllFrequencies(t *testing.T, db *gorm.DB) { + t.Helper() + + var existing int64 + db.Model(&models.TaskFrequency{}).Where("name = ?", "Daily").Count(&existing) + if existing > 0 { + return + } + + days1 := 1 + days14 := 14 + days90 := 90 + days365 := 365 + + extraFreqs := []models.TaskFrequency{ + {Name: "Daily", Days: &days1, DisplayOrder: 0}, + {Name: "Bi-Weekly", Days: &days14, DisplayOrder: 3}, + {Name: "Quarterly", Days: &days90, DisplayOrder: 5}, + {Name: "Yearly", Days: &days365, DisplayOrder: 6}, + {Name: "Custom", Days: nil, DisplayOrder: 7}, + } + for i := range extraFreqs { + err := db.Create(&extraFreqs[i]).Error + require.NoError(t, err, "failed to seed frequency: %s", extraFreqs[i].Name) + } +} + +func TestTaskService_CreateCompletion_OneTime_NextDueDateBecomesNil(t *testing.T) { + 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: "One-time Task", + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Done", + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + assert.Nil(t, reloaded.NextDueDate, "One-time task NextDueDate should be nil after completion") +} + +func TestTaskService_CreateCompletion_OneTime_InProgressBecomesFalse(t *testing.T) { + 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: "In-progress One-time", + DueDate: &dueDate, + NextDueDate: &dueDate, + InProgress: true, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + assert.False(t, reloaded.InProgress, "One-time task InProgress should be false after completion") +} + +func TestTaskService_CreateCompletion_OneTime_CompletionRecordFields(t *testing.T) { + 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, "Record Fields Task") + + now := time.Now().UTC() + resp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Check all fields", + }, user.ID, now) + require.NoError(t, err) + + assert.NotZero(t, resp.Data.ID, "Completion ID should be set") + assert.Equal(t, task.ID, resp.Data.TaskID, "TaskID should match") + assert.Equal(t, "Check all fields", resp.Data.Notes, "Notes should match") + + var completion models.TaskCompletion + err = db.Where("task_id = ?", task.ID).First(&completion).Error + require.NoError(t, err) + assert.Equal(t, user.ID, completion.CompletedByID, "CompletedByID should match user") +} + +func TestTaskService_CreateCompletion_OneTime_WithNotesActualCostRating(t *testing.T) { + 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, "Full Fields Task") + + cost := decimal.NewFromFloat(75.50) + rating := 4 + now := time.Now().UTC() + resp, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + Notes: "Detailed notes", + ActualCost: &cost, + Rating: &rating, + }, user.ID, now) + require.NoError(t, err) + + assert.Equal(t, "Detailed notes", resp.Data.Notes) + require.NotNil(t, resp.Data.ActualCost, "ActualCost should be set") + assert.True(t, resp.Data.ActualCost.Equal(cost), "ActualCost should match") + require.NotNil(t, resp.Data.Rating, "Rating should be set") + assert.Equal(t, 4, *resp.Data.Rating) + + // Verify in database + var completion models.TaskCompletion + err = db.Where("task_id = ?", task.ID).First(&completion).Error + require.NoError(t, err) + require.NotNil(t, completion.ActualCost) + assert.True(t, completion.ActualCost.Equal(cost)) + require.NotNil(t, completion.Rating) + assert.Equal(t, 4, *completion.Rating) +} + +func TestTaskService_CreateCompletion_AlreadyCompleted_SecondCompletionCreated(t *testing.T) { + 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: "Double Complete Task", + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + + // First completion + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "First", + }, user.ID, now) + require.NoError(t, err) + + var afterFirst models.Task + db.First(&afterFirst, task.ID) + assert.Nil(t, afterFirst.NextDueDate, "NextDueDate should be nil after first completion") + + // Second completion on already-completed task + resp2, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Second", + }, user.ID, now) + require.NoError(t, err) + assert.NotZero(t, resp2.Data.ID, "Second completion should be created") + + // NextDueDate should remain nil + var afterSecond models.Task + db.First(&afterSecond, task.ID) + assert.Nil(t, afterSecond.NextDueDate, "NextDueDate should remain nil after second completion") + + // Two completions should exist + var count int64 + db.Model(&models.TaskCompletion{}).Where("task_id = ?", task.ID).Count(&count) + assert.Equal(t, int64(2), count, "Two completions should exist") +} + +func TestTaskService_CreateCompletion_WithBackdatedCompletedAt(t *testing.T) { + 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, "Backdated Task") + + backdated := time.Date(2026, 1, 15, 14, 30, 0, 0, time.UTC) + now := time.Now().UTC() + _, err := service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Backdated", CompletedAt: &backdated, + }, user.ID, now) + require.NoError(t, err) + + var completion models.TaskCompletion + err = db.Where("task_id = ?", task.ID).First(&completion).Error + require.NoError(t, err) + assert.Equal(t, backdated.Year(), completion.CompletedAt.Year()) + assert.Equal(t, backdated.Month(), completion.CompletedAt.Month()) + assert.Equal(t, backdated.Day(), completion.CompletedAt.Day()) +} + +func TestTaskService_CreateCompletion_Recurring_BackdatedCompletedAt_NextDueFromThatDate(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Recurring Backdated", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + backdated := time.Date(2026, 1, 10, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, Notes: "Backdated recurring", CompletedAt: &backdated, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := backdated.AddDate(0, 0, 7) + assert.Equal(t, expected.Year(), reloaded.NextDueDate.Year()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), + "NextDueDate should be calculated from backdated CompletedAt, not current time") +} + +// ============================================================================= +// Part 2: Recurring Task Completion Tests - ALL FREQUENCY TYPES (TDD) +// ============================================================================= + +func TestTaskService_CreateCompletion_Recurring_Daily(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + seedAllFrequencies(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 dailyFreq models.TaskFrequency + db.Where("name = ?", "Daily").First(&dailyFreq) + + dueDate := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Daily Task", + FrequencyID: &dailyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + completedAt1 := time.Date(2026, 3, 26, 9, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt1, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected1 := completedAt1.AddDate(0, 0, 1) + assert.Equal(t, expected1.Year(), reloaded.NextDueDate.Year()) + assert.Equal(t, expected1.Month(), reloaded.NextDueDate.Month()) + assert.Equal(t, expected1.Day(), reloaded.NextDueDate.Day(), + "Daily: NextDueDate should be CompletedAt + 1 day") + + // Complete again + completedAt2 := time.Date(2026, 3, 27, 10, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt2, + }, user.ID, now) + require.NoError(t, err) + + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected2 := completedAt2.AddDate(0, 0, 1) + assert.Equal(t, expected2.Day(), reloaded.NextDueDate.Day(), + "Daily: Second completion NextDueDate should be new CompletedAt + 1 day") +} + +func TestTaskService_CreateCompletion_Recurring_Weekly_OnTime(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Weekly OnTime", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 3, 20, 15, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, 7) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) +} + +func TestTaskService_CreateCompletion_Recurring_Weekly_CompletedLate_NextDueFromCompletedAt(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Weekly Late", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + // Complete 3 days late + completedAt := time.Date(2026, 3, 23, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, 7) // Mar 30, NOT Mar 27 + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), + "NextDueDate should be CompletedAt+7 (Mar 30), not DueDate+7 (Mar 27)") + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) +} + +func TestTaskService_CreateCompletion_Recurring_BiWeekly(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + seedAllFrequencies(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 biWeeklyFreq models.TaskFrequency + db.Where("name = ?", "Bi-Weekly").First(&biWeeklyFreq) + + dueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Bi-Weekly Task", + FrequencyID: &biWeeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, 14) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month(), + "Bi-Weekly: NextDueDate should be CompletedAt + 14 days") +} + +func TestTaskService_CreateCompletion_Recurring_Monthly_Dedicated(t *testing.T) { + 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 monthlyFreq models.TaskFrequency + db.Where("name = ?", "Monthly").First(&monthlyFreq) + + dueDate := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Monthly Dedicated", + FrequencyID: &monthlyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 2, 5, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, 30) + assert.Equal(t, expected.Year(), reloaded.NextDueDate.Year()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), + "Monthly: NextDueDate should be CompletedAt + 30 days") +} + +func TestTaskService_CreateCompletion_Recurring_Quarterly(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + seedAllFrequencies(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 quarterlyFreq models.TaskFrequency + db.Where("name = ?", "Quarterly").First(&quarterlyFreq) + + dueDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Quarterly Task", + FrequencyID: &quarterlyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, 90) + assert.Equal(t, expected.Year(), reloaded.NextDueDate.Year()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), + "Quarterly: NextDueDate should be CompletedAt + 90 days") +} + +func TestTaskService_CreateCompletion_Recurring_Yearly(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + seedAllFrequencies(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 yearlyFreq models.TaskFrequency + db.Where("name = ?", "Yearly").First(&yearlyFreq) + + dueDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Yearly Task", + FrequencyID: &yearlyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 1, 10, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, 365) + assert.Equal(t, expected.Year(), reloaded.NextDueDate.Year()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), + "Yearly: NextDueDate should be CompletedAt + 365 days") +} + +func TestTaskService_CreateCompletion_Recurring_Custom_Intervals(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + seedAllFrequencies(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 customFreq models.TaskFrequency + db.Where("name = ?", "Custom").First(&customFreq) + + tests := []struct { + name string + intervalDays int + }{ + {"Custom_10_days", 10}, + {"Custom_45_days", 45}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + customDays := tc.intervalDays + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: tc.name, + FrequencyID: &customFreq.ID, + CustomIntervalDays: &customDays, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + expected := completedAt.AddDate(0, 0, tc.intervalDays) + assert.Equal(t, expected.Year(), reloaded.NextDueDate.Year()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), + "Custom %d days: NextDueDate should be CompletedAt + %d days", tc.intervalDays, tc.intervalDays) + }) + } +} + +func TestTaskService_CreateCompletion_Recurring_NoDueDate_NextDueFromCompletion(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Recurring No Due Date", + FrequencyID: &weeklyFreq.ID, + DueDate: nil, + NextDueDate: nil, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 3, 26, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate, + "Recurring task with no original DueDate should have NextDueDate after completion") + expected := completedAt.AddDate(0, 0, 7) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day()) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month()) +} + +func TestTaskService_CreateCompletion_Recurring_ThreeSequentialCompletions(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Weekly 3x Complete", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + var reloaded models.Task + + // Completion 1: Mar 1 -> NextDueDate = Mar 8 + c1 := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &c1, + }, user.ID, now) + require.NoError(t, err) + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + assert.Equal(t, 8, reloaded.NextDueDate.Day(), "After 1st: NextDueDate should be Mar 8") + + // Completion 2: Mar 8 -> NextDueDate = Mar 15 + c2 := time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &c2, + }, user.ID, now) + require.NoError(t, err) + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + assert.Equal(t, 15, reloaded.NextDueDate.Day(), "After 2nd: NextDueDate should be Mar 15") + + // Completion 3: Mar 15 -> NextDueDate = Mar 22 + c3 := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &c3, + }, user.ID, now) + require.NoError(t, err) + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate) + assert.Equal(t, 22, reloaded.NextDueDate.Day(), "After 3rd: NextDueDate should be Mar 22") + + var count int64 + db.Model(&models.TaskCompletion{}).Where("task_id = ?", task.ID).Count(&count) + assert.Equal(t, int64(3), count) +} + +func TestTaskService_CreateCompletion_Recurring_InProgressResetForNextCycle(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "InProgress Recurring", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + InProgress: true, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + assert.False(t, reloaded.InProgress, + "Recurring task InProgress should be reset to false after completion for next cycle") + require.NotNil(t, reloaded.NextDueDate) +} + +func TestTaskService_CreateCompletion_CompletedFromColumn_Capture(t *testing.T) { + 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") + + t.Run("overdue_task", func(t *testing.T) { + pastDue := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Overdue Column Task", + DueDate: &pastDue, + NextDueDate: &pastDue, + 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) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + }, user.ID, now) + require.NoError(t, err) + + var completion models.TaskCompletion + db.Where("task_id = ?", task.ID).First(&completion) + assert.Equal(t, "overdue_tasks", completion.CompletedFromColumn, + "CompletedFromColumn should be 'overdue_tasks'") + }) + + t.Run("in_progress_task", func(t *testing.T) { + futureDue := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "InProgress Column Task", + DueDate: &futureDue, + NextDueDate: &futureDue, + InProgress: true, + 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) + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + }, user.ID, now) + require.NoError(t, err) + + var completion models.TaskCompletion + db.Where("task_id = ?", task.ID).First(&completion) + assert.Equal(t, "in_progress_tasks", completion.CompletedFromColumn, + "CompletedFromColumn should be 'in_progress_tasks'") + }) +} + +// Table-driven test: all standard frequency types +func TestTaskService_CreateCompletion_AllFrequencyTypes_TableDriven(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + seedAllFrequencies(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") + + tests := []struct { + freqName string + expectedDays int + }{ + {"Daily", 1}, + {"Weekly", 7}, + {"Bi-Weekly", 14}, + {"Monthly", 30}, + {"Quarterly", 90}, + {"Yearly", 365}, + } + + for _, tc := range tests { + t.Run(tc.freqName, func(t *testing.T) { + var freq models.TaskFrequency + err := db.Where("name = ?", tc.freqName).First(&freq).Error + require.NoError(t, err, "Frequency %s should exist", tc.freqName) + + dueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Freq " + tc.freqName, + FrequencyID: &freq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err = db.Create(task).Error + require.NoError(t, err) + + completedAt := time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC) + now := time.Now().UTC() + _, err = service.CreateCompletion(&requests.CreateTaskCompletionRequest{ + TaskID: task.ID, CompletedAt: &completedAt, + }, user.ID, now) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate, "%s: NextDueDate should not be nil", tc.freqName) + expected := completedAt.AddDate(0, 0, tc.expectedDays) + assert.Equal(t, expected.Year(), reloaded.NextDueDate.Year(), "%s: year", tc.freqName) + assert.Equal(t, expected.Month(), reloaded.NextDueDate.Month(), "%s: month", tc.freqName) + assert.Equal(t, expected.Day(), reloaded.NextDueDate.Day(), "%s: day", tc.freqName) + }) + } +} + +// ============================================================================= +// Part 3: QuickComplete Tests (TDD) +// ============================================================================= + +func TestTaskService_QuickComplete_OneTime_ClearsNextDueDate(t *testing.T) { + 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: "QuickComplete One-time", + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + err = service.QuickComplete(task.ID, user.ID) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + assert.Nil(t, reloaded.NextDueDate, "One-time QuickComplete should set NextDueDate to nil") + + var completion models.TaskCompletion + err = db.Where("task_id = ?", task.ID).First(&completion).Error + require.NoError(t, err, "Completion record should be created") + assert.NotZero(t, completion.ID) +} + +func TestTaskService_QuickComplete_Recurring_RecalculatesNextDueDate(t *testing.T) { + 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 weeklyFreq models.TaskFrequency + db.Where("name = ?", "Weekly").First(&weeklyFreq) + + dueDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "QuickComplete Recurring", + FrequencyID: &weeklyFreq.ID, + DueDate: &dueDate, + NextDueDate: &dueDate, + IsCancelled: false, + IsArchived: false, + Version: 1, + } + err := db.Create(task).Error + require.NoError(t, err) + + err = service.QuickComplete(task.ID, user.ID) + require.NoError(t, err) + + var reloaded models.Task + db.First(&reloaded, task.ID) + require.NotNil(t, reloaded.NextDueDate, "Recurring QuickComplete should set NextDueDate") + expectedApprox := time.Now().UTC().AddDate(0, 0, 7) + assert.WithinDuration(t, expectedApprox, *reloaded.NextDueDate, 2*time.Minute, + "NextDueDate should be ~7 days from now") +} + +func TestTaskService_QuickComplete_SetsWidgetNotes(t *testing.T) { + 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, "Widget Task") + + err := service.QuickComplete(task.ID, user.ID) + require.NoError(t, err) + + var completion models.TaskCompletion + err = db.Where("task_id = ?", task.ID).First(&completion).Error + require.NoError(t, err) + assert.Equal(t, "Completed from widget", completion.Notes, + "QuickComplete should set notes to 'Completed from widget'") +} + +func TestTaskService_QuickComplete_NonExistentTask_ReturnsNotFound(t *testing.T) { + 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") + + err := service.QuickComplete(99999, user.ID) + testutil.AssertAppErrorCode(t, err, http.StatusNotFound) +} + +func TestTaskService_QuickComplete_AccessDenied_ReturnsForbidden(t *testing.T) { + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + taskRepo := repositories.NewTaskRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + service := NewTaskService(taskRepo, residenceRepo) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, owner.ID, "Private Task") + + err := service.QuickComplete(task.ID, otherUser.ID) + testutil.AssertAppError(t, err, http.StatusForbidden, "error.task_access_denied") +} diff --git a/internal/task/predicates/predicates_test.go b/internal/task/predicates/predicates_test.go index ad8a566..ca43c5c 100644 --- a/internal/task/predicates/predicates_test.go +++ b/internal/task/predicates/predicates_test.go @@ -506,7 +506,9 @@ func TestHasCompletions(t *testing.T) { } func TestIsRecurring(t *testing.T) { - days := 7 + days7 := 7 + days0 := 0 + days14 := 14 tests := []struct { name string @@ -514,8 +516,8 @@ func TestIsRecurring(t *testing.T) { expected bool }{ { - name: "recurring: frequency with days", - task: &models.Task{Frequency: &models.TaskFrequency{Days: &days}}, + name: "recurring: frequency with days > 0", + task: &models.Task{Frequency: &models.TaskFrequency{Days: &days7}}, expected: true, }, { @@ -528,6 +530,20 @@ func TestIsRecurring(t *testing.T) { task: &models.Task{Frequency: nil}, expected: false, }, + { + name: "not recurring: frequency with days = 0 (Once)", + task: &models.Task{Frequency: &models.TaskFrequency{Days: &days0}}, + expected: false, + }, + { + name: "recurring: FrequencyID set with CustomIntervalDays", + task: &models.Task{ + FrequencyID: uintPtr(5), + Frequency: &models.TaskFrequency{Days: &days14}, + CustomIntervalDays: intPtr(14), + }, + expected: true, + }, } for _, tt := range tests { @@ -539,3 +555,224 @@ func TestIsRecurring(t *testing.T) { }) } } + +func TestIsOneTime(t *testing.T) { + days7 := 7 + + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "one-time: no frequency", + task: &models.Task{Frequency: nil}, + expected: true, + }, + { + name: "one-time: Once frequency (days nil)", + task: &models.Task{Frequency: &models.TaskFrequency{Name: "Once", Days: nil}}, + expected: true, + }, + { + name: "not one-time: recurring frequency", + task: &models.Task{Frequency: &models.TaskFrequency{Name: "Weekly", Days: &days7}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsOneTime(tt.task) + if result != tt.expected { + t.Errorf("IsOneTime() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsDueSoon_AdditionalCases(t *testing.T) { + now := time.Now().UTC() + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + tests := []struct { + name string + task *models.Task + now time.Time + daysThreshold int + expected bool + }{ + { + name: "not due soon: no due date", + task: &models.Task{ + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + { + name: "due soon: task due today (start of day)", + task: &models.Task{ + NextDueDate: timePtr(startOfToday), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold) + if result != tt.expected { + t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestHasCompletions_CompletionCount(t *testing.T) { + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "has completions via CompletionCount", + task: &models.Task{Completions: nil, CompletionCount: 3}, + expected: true, + }, + { + name: "no completions: CompletionCount zero, no preloaded completions", + task: &models.Task{Completions: nil, CompletionCount: 0}, + expected: false, + }, + { + name: "has completions via preloaded slice (ignores CompletionCount)", + task: &models.Task{ + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + CompletionCount: 0, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.HasCompletions(tt.task) + if result != tt.expected { + t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGetCompletionCount(t *testing.T) { + tests := []struct { + name string + task *models.Task + expected int + }{ + { + name: "zero completions: empty slice", + task: &models.Task{Completions: []models.TaskCompletion{}, CompletionCount: 0}, + expected: 0, + }, + { + name: "three completions via preloaded slice", + task: &models.Task{ + Completions: []models.TaskCompletion{ + {BaseModel: models.BaseModel{ID: 1}}, + {BaseModel: models.BaseModel{ID: 2}}, + {BaseModel: models.BaseModel{ID: 3}}, + }, + }, + expected: 3, + }, + { + name: "count via CompletionCount when slice is empty", + task: &models.Task{Completions: nil, CompletionCount: 5}, + expected: 5, + }, + { + name: "nil completions and zero CompletionCount", + task: &models.Task{Completions: nil, CompletionCount: 0}, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.GetCompletionCount(tt.task) + if result != tt.expected { + t.Errorf("GetCompletionCount() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsUpcoming_AdditionalCases(t *testing.T) { + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + + tests := []struct { + name string + task *models.Task + now time.Time + daysThreshold int + expected bool + }{ + { + name: "not upcoming: overdue task", + task: &models.Task{ + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + { + name: "not upcoming: completed task", + task: &models.Task{ + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold) + if result != tt.expected { + t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected) + } + }) + } +} + +// Helper to create a uint pointer +func uintPtr(v uint) *uint { + return &v +} + +// Helper to create an int pointer +func intPtr(v int) *int { + return &v +}