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:
952
internal/services/task_service_state_transition_test.go
Normal file
952
internal/services/task_service_state_transition_test.go
Normal 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")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user