65a9aae4e5
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>
954 lines
35 KiB
Go
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")
|
|
}
|