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:
@@ -840,3 +840,159 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
|
|||||||
assert.IsType(t, []interface{}{}, response["columns"])
|
assert.IsType(t, []interface{}{}, response["columns"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Part 3: Handler-Level Edge Cases (TDD)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestTaskHandler_CreateCompletion_RatingValidation(t *testing.T) {
|
||||||
|
handler, e, db := setupTaskHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/task-completions")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.POST("/", handler.CreateCompletion)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rating int
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"rating_0_rejected", 0, http.StatusBadRequest},
|
||||||
|
{"rating_negative1_rejected", -1, http.StatusBadRequest},
|
||||||
|
{"rating_1_accepted", 1, http.StatusCreated},
|
||||||
|
{"rating_5_accepted", 5, http.StatusCreated},
|
||||||
|
{"rating_6_rejected", 6, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create a fresh task for each accepted rating (otherwise it's completed)
|
||||||
|
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Rate Me "+tt.name)
|
||||||
|
completedAt := time.Now().UTC()
|
||||||
|
rating := tt.rating
|
||||||
|
req := requests.CreateTaskCompletionRequest{
|
||||||
|
TaskID: task.ID,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
Rating: &rating,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, tt.wantStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskHandler_CreateTask_TitleBoundary(t *testing.T) {
|
||||||
|
handler, e, db := setupTaskHandler(t)
|
||||||
|
testutil.SeedLookupData(t, db)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/tasks")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.POST("/", handler.CreateTask)
|
||||||
|
|
||||||
|
t.Run("title_exactly_200_chars_accepted", func(t *testing.T) {
|
||||||
|
title200 := ""
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
title200 += "A"
|
||||||
|
}
|
||||||
|
req := requests.CreateTaskRequest{
|
||||||
|
ResidenceID: residence.ID,
|
||||||
|
Title: title200,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("title_201_chars_rejected", func(t *testing.T) {
|
||||||
|
title201 := ""
|
||||||
|
for i := 0; i < 201; i++ {
|
||||||
|
title201 += "A"
|
||||||
|
}
|
||||||
|
req := requests.CreateTaskRequest{
|
||||||
|
ResidenceID: residence.ID,
|
||||||
|
Title: title201,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskHandler_CreateTask_DescriptionBoundary(t *testing.T) {
|
||||||
|
handler, e, db := setupTaskHandler(t)
|
||||||
|
testutil.SeedLookupData(t, db)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/tasks")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.POST("/", handler.CreateTask)
|
||||||
|
|
||||||
|
t.Run("description_exactly_10000_chars_accepted", func(t *testing.T) {
|
||||||
|
desc10000 := ""
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
desc10000 += "B"
|
||||||
|
}
|
||||||
|
req := requests.CreateTaskRequest{
|
||||||
|
ResidenceID: residence.ID,
|
||||||
|
Title: "Long Desc Task",
|
||||||
|
Description: desc10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskHandler_CreateTask_CustomIntervalDaysValidation(t *testing.T) {
|
||||||
|
handler, e, db := setupTaskHandler(t)
|
||||||
|
testutil.SeedLookupData(t, db)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/tasks")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.POST("/", handler.CreateTask)
|
||||||
|
|
||||||
|
t.Run("custom_interval_days_0_rejected", func(t *testing.T) {
|
||||||
|
// Validation tag: min=1, so 0 should be rejected
|
||||||
|
interval := 0
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"residence_id": residence.ID,
|
||||||
|
"title": "Custom Interval Zero",
|
||||||
|
"custom_interval_days": interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom_interval_days_negative1_rejected", func(t *testing.T) {
|
||||||
|
interval := -1
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"residence_id": residence.ID,
|
||||||
|
"title": "Custom Interval Negative",
|
||||||
|
"custom_interval_days": interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom_interval_days_1_accepted", func(t *testing.T) {
|
||||||
|
interval := 1
|
||||||
|
req := requests.CreateTaskRequest{
|
||||||
|
ResidenceID: residence.ID,
|
||||||
|
Title: "Custom Interval One",
|
||||||
|
CustomIntervalDays: &interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
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
@@ -506,7 +506,9 @@ func TestHasCompletions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIsRecurring(t *testing.T) {
|
func TestIsRecurring(t *testing.T) {
|
||||||
days := 7
|
days7 := 7
|
||||||
|
days0 := 0
|
||||||
|
days14 := 14
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -514,8 +516,8 @@ func TestIsRecurring(t *testing.T) {
|
|||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "recurring: frequency with days",
|
name: "recurring: frequency with days > 0",
|
||||||
task: &models.Task{Frequency: &models.TaskFrequency{Days: &days}},
|
task: &models.Task{Frequency: &models.TaskFrequency{Days: &days7}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -528,6 +530,20 @@ func TestIsRecurring(t *testing.T) {
|
|||||||
task: &models.Task{Frequency: nil},
|
task: &models.Task{Frequency: nil},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "not recurring: frequency with days = 0 (Once)",
|
||||||
|
task: &models.Task{Frequency: &models.TaskFrequency{Days: &days0}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recurring: FrequencyID set with CustomIntervalDays",
|
||||||
|
task: &models.Task{
|
||||||
|
FrequencyID: uintPtr(5),
|
||||||
|
Frequency: &models.TaskFrequency{Days: &days14},
|
||||||
|
CustomIntervalDays: intPtr(14),
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -539,3 +555,224 @@ func TestIsRecurring(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsOneTime(t *testing.T) {
|
||||||
|
days7 := 7
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task *models.Task
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "one-time: no frequency",
|
||||||
|
task: &models.Task{Frequency: nil},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one-time: Once frequency (days nil)",
|
||||||
|
task: &models.Task{Frequency: &models.TaskFrequency{Name: "Once", Days: nil}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not one-time: recurring frequency",
|
||||||
|
task: &models.Task{Frequency: &models.TaskFrequency{Name: "Weekly", Days: &days7}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := predicates.IsOneTime(tt.task)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsOneTime() = %v, expected %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDueSoon_AdditionalCases(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task *models.Task
|
||||||
|
now time.Time
|
||||||
|
daysThreshold int
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not due soon: no due date",
|
||||||
|
task: &models.Task{
|
||||||
|
NextDueDate: nil,
|
||||||
|
DueDate: nil,
|
||||||
|
IsCancelled: false,
|
||||||
|
IsArchived: false,
|
||||||
|
Completions: []models.TaskCompletion{},
|
||||||
|
},
|
||||||
|
now: now,
|
||||||
|
daysThreshold: 30,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "due soon: task due today (start of day)",
|
||||||
|
task: &models.Task{
|
||||||
|
NextDueDate: timePtr(startOfToday),
|
||||||
|
IsCancelled: false,
|
||||||
|
IsArchived: false,
|
||||||
|
Completions: []models.TaskCompletion{},
|
||||||
|
},
|
||||||
|
now: now,
|
||||||
|
daysThreshold: 30,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasCompletions_CompletionCount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task *models.Task
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "has completions via CompletionCount",
|
||||||
|
task: &models.Task{Completions: nil, CompletionCount: 3},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no completions: CompletionCount zero, no preloaded completions",
|
||||||
|
task: &models.Task{Completions: nil, CompletionCount: 0},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has completions via preloaded slice (ignores CompletionCount)",
|
||||||
|
task: &models.Task{
|
||||||
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
|
||||||
|
CompletionCount: 0,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := predicates.HasCompletions(tt.task)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCompletionCount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task *models.Task
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zero completions: empty slice",
|
||||||
|
task: &models.Task{Completions: []models.TaskCompletion{}, CompletionCount: 0},
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three completions via preloaded slice",
|
||||||
|
task: &models.Task{
|
||||||
|
Completions: []models.TaskCompletion{
|
||||||
|
{BaseModel: models.BaseModel{ID: 1}},
|
||||||
|
{BaseModel: models.BaseModel{ID: 2}},
|
||||||
|
{BaseModel: models.BaseModel{ID: 3}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "count via CompletionCount when slice is empty",
|
||||||
|
task: &models.Task{Completions: nil, CompletionCount: 5},
|
||||||
|
expected: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil completions and zero CompletionCount",
|
||||||
|
task: &models.Task{Completions: nil, CompletionCount: 0},
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := predicates.GetCompletionCount(tt.task)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetCompletionCount() = %v, expected %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUpcoming_AdditionalCases(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
yesterday := now.AddDate(0, 0, -1)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task *models.Task
|
||||||
|
now time.Time
|
||||||
|
daysThreshold int
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not upcoming: overdue task",
|
||||||
|
task: &models.Task{
|
||||||
|
NextDueDate: timePtr(yesterday),
|
||||||
|
IsCancelled: false,
|
||||||
|
IsArchived: false,
|
||||||
|
Completions: []models.TaskCompletion{},
|
||||||
|
},
|
||||||
|
now: now,
|
||||||
|
daysThreshold: 30,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not upcoming: completed task",
|
||||||
|
task: &models.Task{
|
||||||
|
NextDueDate: nil,
|
||||||
|
DueDate: nil,
|
||||||
|
IsCancelled: false,
|
||||||
|
IsArchived: false,
|
||||||
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
|
||||||
|
},
|
||||||
|
now: now,
|
||||||
|
daysThreshold: 30,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a uint pointer
|
||||||
|
func uintPtr(v uint) *uint {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create an int pointer
|
||||||
|
func intPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user