Files
honeyDueAPI/internal/services/task_service_state_transition_test.go
T
Trey t 65a9aae4e5
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Migrate TaskService + ResidenceService to ctx-aware repos
Every public method on TaskService and ResidenceService now takes
ctx context.Context as the first arg and routes its repo calls through
.WithContext(ctx). With otelgorm registered, this means every API
endpoint backed by these two services produces a flame graph in Jaeger
where the SQL spans nest under the parent HTTP request span — instead
of appearing as orphaned queries.

Endpoints now fully traced (HTTP → service → SQL):
- GET    /api/tasks/                       (already shipped)
- GET    /api/tasks/by-residence/:id/      (already shipped)
- GET    /api/tasks/:id/
- POST   /api/tasks/
- POST   /api/tasks/bulk/
- PUT    /api/tasks/:id/
- DELETE /api/tasks/:id/
- POST   /api/tasks/:id/in-progress/
- POST   /api/tasks/:id/cancel/
- POST   /api/tasks/:id/uncancel/
- POST   /api/tasks/:id/archive/
- POST   /api/tasks/:id/unarchive/
- POST   /api/tasks/:id/complete/
- POST   /api/tasks/:id/quick-complete/
- GET    /api/tasks/completions/* (CRUD)
- GET    /api/static_data/ (categories, priorities, frequencies)
- GET    /api/residences/
- GET    /api/residences/my/
- GET    /api/residences/summary/
- GET    /api/residences/:id/
- POST   /api/residences/
- PUT    /api/residences/:id/
- DELETE /api/residences/:id/
- Share-code + member management endpoints
- GET    /api/residences/:id/report/

Mechanical work: ~50 method signatures, ~80 handler call sites,
~25 test call sites updated. Internal sendTaskCompletedNotification
helper also takes ctx so background notification SQL nests correctly.

The remaining services (ContractorService, DocumentService,
AuthService, NotificationService, SubscriptionService) follow the same
pattern; they continue to emit untraced SQL until migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:04:01 -05:00

954 lines
35 KiB
Go

package services
import (
"context"
"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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), completionReq, user.ID, now)
require.NoError(t, err)
// Step 2: Archive the completed task
archiveResp, err := service.ArchiveTask(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), task.ID, user.ID, now)
require.NoError(t, err)
_, err = service.UncancelTask(context.Background(), task.ID, user.ID, now)
require.NoError(t, err)
// Cycle 2: Cancel -> Uncancel
_, err = service.CancelTask(context.Background(), task.ID, user.ID, now)
require.NoError(t, err)
uncancelResp, err := service.UncancelTask(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), task.ID, user.ID, now)
require.NoError(t, err)
assert.True(t, cancelResp.Data.IsCancelled, "Service cancel should succeed with correct version")
}