Comprehensive TDD test suite for task logic — ~80 new tests

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.
This commit is contained in:
Trey T
2026-03-26 17:36:50 -05:00
parent 7f0300cc95
commit 4c9a818bd9
4 changed files with 3546 additions and 3 deletions

View File

@@ -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")
}