Database Indexes (migrations 006-009): - Add case-insensitive indexes for auth lookups (email, username) - Add composite indexes for task kanban queries - Add indexes for notification, document, and completion queries - Add unique index for active share codes - Remove redundant idx_share_code_active and idx_notification_user_sent Repository Optimizations: - Add FindResidenceIDsByUser() lightweight method (IDs only, no preloads) - Optimize GetResidenceUsers() with single UNION query (was 2 queries) - Optimize kanban completion preloads to minimal columns (id, task_id, completed_at) Service Optimizations: - Remove Category/Priority/Frequency preloads from task queries - Remove summary calculations from CRUD responses (client calculates) - Use lightweight FindResidenceIDsByUser() instead of full FindByUser() These changes reduce database load and response times for common operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1106 lines
33 KiB
Go
1106 lines
33 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/treytartt/casera-api/internal/dto/responses"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-api/internal/repositories"
|
|
"github.com/treytartt/casera-api/internal/testutil"
|
|
)
|
|
|
|
/*
|
|
================================================================================
|
|
TASK CATEGORIZATION LOGIC - COMPREHENSIVE TEST SUITE
|
|
================================================================================
|
|
|
|
This file contains the definitive tests for task categorization logic.
|
|
The categorization determines which kanban column a task appears in.
|
|
|
|
CORE CONCEPTS:
|
|
-------------
|
|
1. DueDate: The original due date set when task was created
|
|
2. NextDueDate: The "effective" due date used for categorization
|
|
- For new tasks: NextDueDate = DueDate
|
|
- For completed one-time tasks: NextDueDate = nil
|
|
- For completed recurring tasks: NextDueDate = CompletedAt + FrequencyDays
|
|
|
|
KANBAN COLUMNS (in priority order):
|
|
----------------------------------
|
|
1. CANCELLED: Task.IsCancelled = true
|
|
2. COMPLETED: NextDueDate = nil AND has completions (one-time task done)
|
|
3. IN_PROGRESS: InProgress = true
|
|
4. OVERDUE: NextDueDate < now
|
|
5. DUE_SOON: NextDueDate < now + daysThreshold (default 30)
|
|
6. UPCOMING: Everything else (NextDueDate >= threshold or no due date)
|
|
|
|
RECURRING TASK BEHAVIOR:
|
|
-----------------------
|
|
- Recurring tasks NEVER go to "Completed" column permanently
|
|
- After completion, NextDueDate is updated to CompletedAt + FrequencyDays
|
|
- They cycle through: Upcoming -> Due Soon -> Overdue -> (complete) -> Upcoming...
|
|
|
|
ONE-TIME TASK BEHAVIOR:
|
|
----------------------
|
|
- One-time tasks (frequency=Once or frequency=nil) go to "Completed" when done
|
|
- After completion, NextDueDate = nil signals permanent completion
|
|
- They stay in "Completed" column forever
|
|
|
|
================================================================================
|
|
*/
|
|
|
|
// ============================================================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================================================
|
|
|
|
// ptr returns a pointer to the given value
|
|
func ptr[T any](v T) *T {
|
|
return &v
|
|
}
|
|
|
|
// daysFromNow returns a time.Time that is n days from now
|
|
func daysFromNow(n int) time.Time {
|
|
return time.Now().UTC().AddDate(0, 0, n)
|
|
}
|
|
|
|
// daysAgo returns a time.Time that is n days ago
|
|
func daysAgo(n int) time.Time {
|
|
return time.Now().UTC().AddDate(0, 0, -n)
|
|
}
|
|
|
|
// isTaskCompleted checks if a task is permanently completed (one-time task done).
|
|
// A task is completed when it has completions AND NextDueDate is nil.
|
|
func isTaskCompleted(task *models.Task) bool {
|
|
if len(task.Completions) == 0 {
|
|
return false
|
|
}
|
|
return task.NextDueDate == nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// isTaskCompleted FUNCTION TESTS
|
|
// ============================================================================
|
|
|
|
func TestIsTaskCompleted_NoCompletions_ReturnsFalse(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(10)),
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
assert.False(t, isTaskCompleted(task),
|
|
"Task with no completions should not be considered completed")
|
|
}
|
|
|
|
func TestIsTaskCompleted_HasCompletions_WithNextDueDate_ReturnsFalse(t *testing.T) {
|
|
// This is a RECURRING task - has completions but NextDueDate is set for next occurrence
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(10)),
|
|
Completions: []models.TaskCompletion{
|
|
{CompletedAt: daysAgo(5)},
|
|
},
|
|
}
|
|
|
|
assert.False(t, isTaskCompleted(task),
|
|
"Recurring task with NextDueDate should not be considered completed")
|
|
}
|
|
|
|
func TestIsTaskCompleted_HasCompletions_NilNextDueDate_ReturnsTrue(t *testing.T) {
|
|
// This is a completed ONE-TIME task - NextDueDate is nil
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{
|
|
{CompletedAt: daysAgo(5)},
|
|
},
|
|
}
|
|
|
|
assert.True(t, isTaskCompleted(task),
|
|
"One-time task with nil NextDueDate and completions should be considered completed")
|
|
}
|
|
|
|
func TestIsTaskCompleted_NoCompletions_NilNextDueDate_ReturnsFalse(t *testing.T) {
|
|
// Task with no due date and no completions - not completed
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{},
|
|
}
|
|
|
|
assert.False(t, isTaskCompleted(task),
|
|
"Task with nil NextDueDate but no completions should not be considered completed")
|
|
}
|
|
|
|
// ============================================================================
|
|
// GetButtonTypesForTask FUNCTION TESTS
|
|
// ============================================================================
|
|
|
|
func TestGetButtonTypesForTask_CancelledTask(t *testing.T) {
|
|
task := &models.Task{
|
|
IsCancelled: true,
|
|
NextDueDate: ptr(daysAgo(5)), // Even if overdue, cancelled takes precedence
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.ElementsMatch(t, []string{"uncancel", "delete"}, buttons,
|
|
"Cancelled task should only have uncancel and delete buttons")
|
|
}
|
|
|
|
func TestGetButtonTypesForTask_CompletedOneTimeTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{
|
|
{CompletedAt: daysAgo(5)},
|
|
},
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.Empty(t, buttons,
|
|
"Completed one-time task should have no buttons (read-only)")
|
|
}
|
|
|
|
func TestGetButtonTypesForTask_InProgressTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(10)),
|
|
InProgress: true,
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.ElementsMatch(t, []string{"edit", "complete", "cancel"}, buttons,
|
|
"In Progress task should not have mark_in_progress button")
|
|
}
|
|
|
|
func TestGetButtonTypesForTask_OverdueTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysAgo(5)),
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
|
|
"Overdue task should have all action buttons")
|
|
}
|
|
|
|
func TestGetButtonTypesForTask_DueSoonTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(15)), // Within 30-day threshold
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
|
|
"Due soon task should have all action buttons")
|
|
}
|
|
|
|
func TestGetButtonTypesForTask_UpcomingTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(45)), // Beyond 30-day threshold
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
|
|
"Upcoming task should have all action buttons")
|
|
}
|
|
|
|
func TestGetButtonTypesForTask_TaskWithNoDueDate(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: nil,
|
|
}
|
|
|
|
buttons := GetButtonTypesForTask(task, 30)
|
|
|
|
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, buttons,
|
|
"Task with no due date should have all action buttons")
|
|
}
|
|
|
|
// ============================================================================
|
|
// GetIOSCategoryForTask FUNCTION TESTS
|
|
// ============================================================================
|
|
|
|
func TestGetIOSCategoryForTask_CancelledTask(t *testing.T) {
|
|
task := &models.Task{IsCancelled: true}
|
|
|
|
category := GetIOSCategoryForTask(task)
|
|
|
|
assert.Equal(t, IOSCategoryTaskCancelled, category)
|
|
}
|
|
|
|
func TestGetIOSCategoryForTask_CompletedTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
|
}
|
|
|
|
category := GetIOSCategoryForTask(task)
|
|
|
|
assert.Equal(t, IOSCategoryTaskCompleted, category)
|
|
}
|
|
|
|
func TestGetIOSCategoryForTask_InProgressTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(10)),
|
|
InProgress: true,
|
|
}
|
|
|
|
category := GetIOSCategoryForTask(task)
|
|
|
|
assert.Equal(t, IOSCategoryTaskInProgress, category)
|
|
}
|
|
|
|
func TestGetIOSCategoryForTask_ActionableTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(10)),
|
|
}
|
|
|
|
category := GetIOSCategoryForTask(task)
|
|
|
|
assert.Equal(t, IOSCategoryTaskActionable, category)
|
|
}
|
|
|
|
// ============================================================================
|
|
// DetermineKanbanColumn FUNCTION TESTS
|
|
// ============================================================================
|
|
|
|
func TestDetermineKanbanColumn_CancelledTask(t *testing.T) {
|
|
task := &models.Task{
|
|
IsCancelled: true,
|
|
NextDueDate: ptr(daysAgo(5)), // Even overdue
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "cancelled_tasks", column,
|
|
"Cancelled takes precedence over everything")
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_CompletedOneTimeTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "completed_tasks", column)
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_InProgressTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysAgo(5)), // Even overdue
|
|
InProgress: true,
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "in_progress_tasks", column,
|
|
"In Progress takes precedence over date-based columns")
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_OverdueTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysAgo(5)),
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "overdue_tasks", column)
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_DueSoonTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(15)),
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "due_soon_tasks", column)
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_UpcomingTask(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(45)),
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "upcoming_tasks", column)
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_NoDueDate(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: nil,
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "upcoming_tasks", column,
|
|
"Tasks with no due date go to upcoming")
|
|
}
|
|
|
|
func TestDetermineKanbanColumn_FallbackToDueDate(t *testing.T) {
|
|
// Task with DueDate set but NextDueDate nil (legacy data)
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: ptr(daysAgo(5)),
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "overdue_tasks", column,
|
|
"Should fallback to DueDate when NextDueDate is nil")
|
|
}
|
|
|
|
// ============================================================================
|
|
// ONE-TIME TASK LIFECYCLE TESTS (Integration)
|
|
// ============================================================================
|
|
|
|
func TestOneTimeTask_Lifecycle_Creation(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
testutil.SeedLookupData(t, db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
_ = repositories.NewResidenceRepository(db)
|
|
|
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
|
|
|
// Get "Once" frequency
|
|
var onceFreq models.TaskFrequency
|
|
db.Where("name = ?", "Once").First(&onceFreq)
|
|
|
|
dueDate := daysFromNow(10)
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "One-Time Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &onceFreq.ID,
|
|
}
|
|
err := db.Create(task).Error
|
|
require.NoError(t, err)
|
|
|
|
// Reload with frequency
|
|
task, err = taskRepo.FindByID(task.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify initial state
|
|
assert.NotNil(t, task.NextDueDate, "NextDueDate should be set on creation")
|
|
assert.Equal(t, task.DueDate.Unix(), task.NextDueDate.Unix(),
|
|
"NextDueDate should equal DueDate on creation")
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"Task due in 10 days should be in due_soon")
|
|
}
|
|
|
|
func TestOneTimeTask_Lifecycle_Completion(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
testutil.SeedLookupData(t, db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
_ = repositories.NewResidenceRepository(db)
|
|
|
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
|
|
|
// Get "Once" frequency
|
|
var onceFreq models.TaskFrequency
|
|
db.Where("name = ?", "Once").First(&onceFreq)
|
|
|
|
dueDate := daysFromNow(10)
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "One-Time Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &onceFreq.ID,
|
|
}
|
|
err := db.Create(task).Error
|
|
require.NoError(t, err)
|
|
|
|
// Simulate completion (what task_service.CreateCompletion does)
|
|
completedAt := time.Now().UTC()
|
|
completion := &models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: completedAt,
|
|
}
|
|
err = taskRepo.CreateCompletion(completion)
|
|
require.NoError(t, err)
|
|
|
|
// Reload task to get frequency
|
|
task, err = taskRepo.FindByID(task.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Simulate the next_due_date update (from task_service.CreateCompletion)
|
|
// For one-time task: NextDueDate becomes nil
|
|
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
|
|
task.NextDueDate = nil
|
|
}
|
|
err = taskRepo.Update(task)
|
|
require.NoError(t, err)
|
|
|
|
// Reload and verify
|
|
task, err = taskRepo.FindByID(task.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Nil(t, task.NextDueDate,
|
|
"NextDueDate should be nil after one-time task completion")
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "completed_tasks", column,
|
|
"Completed one-time task should be in completed_tasks")
|
|
}
|
|
|
|
// ============================================================================
|
|
// RECURRING TASK LIFECYCLE TESTS (Integration)
|
|
// ============================================================================
|
|
|
|
func TestRecurringTask_Lifecycle_Creation(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Weekly" frequency
|
|
var weeklyFreq models.TaskFrequency
|
|
db.Where("name = ?", "Weekly").First(&weeklyFreq)
|
|
|
|
dueDate := daysFromNow(3) // Due in 3 days
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "Weekly Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &weeklyFreq.ID,
|
|
}
|
|
err := db.Create(task).Error
|
|
require.NoError(t, err)
|
|
|
|
// Reload with frequency
|
|
task, err = taskRepo.FindByID(task.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, task.NextDueDate)
|
|
assert.Equal(t, task.DueDate.Unix(), task.NextDueDate.Unix())
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "due_soon_tasks", column)
|
|
}
|
|
|
|
func TestRecurringTask_Lifecycle_FirstCompletion(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Weekly" frequency (7 days)
|
|
var weeklyFreq models.TaskFrequency
|
|
db.Where("name = ?", "Weekly").First(&weeklyFreq)
|
|
require.NotNil(t, weeklyFreq.Days)
|
|
|
|
dueDate := daysFromNow(3)
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "Weekly Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &weeklyFreq.ID,
|
|
}
|
|
err := db.Create(task).Error
|
|
require.NoError(t, err)
|
|
|
|
// Complete the task
|
|
completedAt := time.Now().UTC()
|
|
completion := &models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: completedAt,
|
|
}
|
|
err = taskRepo.CreateCompletion(completion)
|
|
require.NoError(t, err)
|
|
|
|
// Reload task
|
|
task, err = taskRepo.FindByID(task.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Simulate the next_due_date update (from task_service.CreateCompletion)
|
|
// For recurring task: NextDueDate = CompletedAt + FrequencyDays
|
|
// Note: Frequency is no longer preloaded for performance, use the weeklyFreq we already have
|
|
nextDue := completedAt.AddDate(0, 0, *weeklyFreq.Days)
|
|
task.NextDueDate = &nextDue
|
|
err = taskRepo.Update(task)
|
|
require.NoError(t, err)
|
|
|
|
// Reload and verify
|
|
task, err = taskRepo.FindByID(task.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, task.NextDueDate,
|
|
"NextDueDate should NOT be nil for recurring task")
|
|
|
|
// NextDueDate should be ~7 days from completion
|
|
expectedNextDue := completedAt.AddDate(0, 0, 7)
|
|
assert.WithinDuration(t, expectedNextDue, *task.NextDueDate, time.Hour,
|
|
"NextDueDate should be 7 days after completion")
|
|
|
|
// Task should NOT be in completed column
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.NotEqual(t, "completed_tasks", column,
|
|
"Recurring task should NOT be in completed_tasks after completion")
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"Recurring task should be in due_soon (next due in 7 days)")
|
|
}
|
|
|
|
func TestRecurringTask_Lifecycle_MultipleCompletions(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Weekly" frequency
|
|
var weeklyFreq models.TaskFrequency
|
|
db.Where("name = ?", "Weekly").First(&weeklyFreq)
|
|
|
|
// Create task due 3 days ago (overdue)
|
|
dueDate := daysAgo(3)
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "Weekly Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &weeklyFreq.ID,
|
|
}
|
|
err := db.Create(task).Error
|
|
require.NoError(t, err)
|
|
|
|
// Initially overdue
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "overdue_tasks", column, "Task should start as overdue")
|
|
|
|
// First completion
|
|
completedAt1 := time.Now().UTC()
|
|
db.Create(&models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: completedAt1,
|
|
})
|
|
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
// Note: Frequency is no longer preloaded for performance, use weeklyFreq we already have
|
|
nextDue1 := completedAt1.AddDate(0, 0, *weeklyFreq.Days)
|
|
task.NextDueDate = &nextDue1
|
|
taskRepo.Update(task)
|
|
|
|
// After first completion, should be due_soon
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
column = responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"After completion, task should move to due_soon")
|
|
|
|
// Simulate waiting until overdue again
|
|
overdueNextDue := daysAgo(2)
|
|
task.NextDueDate = &overdueNextDue
|
|
taskRepo.Update(task)
|
|
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
column = responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "overdue_tasks", column,
|
|
"Task should be overdue again when NextDueDate passes")
|
|
|
|
// Second completion
|
|
completedAt2 := time.Now().UTC()
|
|
db.Create(&models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: completedAt2,
|
|
})
|
|
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
// Note: Frequency is no longer preloaded for performance, use weeklyFreq we already have
|
|
nextDue2 := completedAt2.AddDate(0, 0, *weeklyFreq.Days)
|
|
task.NextDueDate = &nextDue2
|
|
taskRepo.Update(task)
|
|
|
|
// After second completion
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
column = responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"After second completion, task moves back to due_soon")
|
|
|
|
// Verify we have 2 completions but task is NOT completed
|
|
assert.Len(t, task.Completions, 2)
|
|
assert.NotNil(t, task.NextDueDate,
|
|
"NextDueDate should never be nil for recurring task")
|
|
}
|
|
|
|
func TestRecurringTask_NeverInCompletedColumn(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Weekly" frequency
|
|
var weeklyFreq models.TaskFrequency
|
|
db.Where("name = ?", "Weekly").First(&weeklyFreq)
|
|
|
|
dueDate := daysFromNow(3)
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "Weekly Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &weeklyFreq.ID,
|
|
}
|
|
db.Create(task)
|
|
|
|
// Add 10 completions
|
|
for i := 0; i < 10; i++ {
|
|
db.Create(&models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: daysAgo(i * 7), // One per week
|
|
})
|
|
}
|
|
|
|
// Update NextDueDate as if properly maintained
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
nextDue := daysFromNow(4)
|
|
task.NextDueDate = &nextDue
|
|
taskRepo.Update(task)
|
|
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
|
|
// Despite having 10 completions, should NOT be in completed column
|
|
assert.Len(t, task.Completions, 10)
|
|
assert.NotNil(t, task.NextDueDate)
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.NotEqual(t, "completed_tasks", column,
|
|
"Recurring task should NEVER be in completed_tasks regardless of completion count")
|
|
}
|
|
|
|
// ============================================================================
|
|
// KANBAN BOARD INTEGRATION TESTS
|
|
// ============================================================================
|
|
|
|
func TestKanbanBoard_OneTimeTaskCompletion_MovesToCompleted(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Once" frequency
|
|
var onceFreq models.TaskFrequency
|
|
db.Where("name = ?", "Once").First(&onceFreq)
|
|
|
|
dueDate := daysFromNow(10)
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "One-Time Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &onceFreq.ID,
|
|
}
|
|
db.Create(task)
|
|
|
|
// Before completion - should be in due_soon
|
|
board, err := taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
|
|
dueSoonCount := 0
|
|
completedCount := 0
|
|
for _, col := range board.Columns {
|
|
if col.Name == "due_soon_tasks" {
|
|
dueSoonCount = col.Count
|
|
}
|
|
if col.Name == "completed_tasks" {
|
|
completedCount = col.Count
|
|
}
|
|
}
|
|
assert.Equal(t, 1, dueSoonCount, "Task should be in due_soon before completion")
|
|
assert.Equal(t, 0, completedCount, "Completed should be empty before completion")
|
|
|
|
// Complete the task
|
|
db.Create(&models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: time.Now().UTC(),
|
|
})
|
|
task.NextDueDate = nil // One-time task
|
|
db.Save(task)
|
|
|
|
// After completion - should be in completed
|
|
board, err = taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
|
|
for _, col := range board.Columns {
|
|
if col.Name == "due_soon_tasks" {
|
|
dueSoonCount = col.Count
|
|
}
|
|
if col.Name == "completed_tasks" {
|
|
completedCount = col.Count
|
|
}
|
|
}
|
|
assert.Equal(t, 0, dueSoonCount, "due_soon should be empty after completion")
|
|
assert.Equal(t, 1, completedCount, "Task should be in completed after completion")
|
|
}
|
|
|
|
func TestKanbanBoard_RecurringTaskCompletion_StaysActionable(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Weekly" frequency
|
|
var weeklyFreq models.TaskFrequency
|
|
db.Where("name = ?", "Weekly").First(&weeklyFreq)
|
|
|
|
dueDate := daysAgo(1) // Overdue by 1 day
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "Weekly Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &weeklyFreq.ID,
|
|
}
|
|
db.Create(task)
|
|
|
|
// Before completion - should be overdue
|
|
board, err := taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
|
|
var overdueCount, completedCount, dueSoonCount int
|
|
for _, col := range board.Columns {
|
|
switch col.Name {
|
|
case "overdue_tasks":
|
|
overdueCount = col.Count
|
|
case "completed_tasks":
|
|
completedCount = col.Count
|
|
case "due_soon_tasks":
|
|
dueSoonCount = col.Count
|
|
}
|
|
}
|
|
assert.Equal(t, 1, overdueCount, "Task should be overdue before completion")
|
|
assert.Equal(t, 0, completedCount, "Completed should be empty")
|
|
|
|
// Complete the task
|
|
completedAt := time.Now().UTC()
|
|
db.Create(&models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: completedAt,
|
|
})
|
|
|
|
// Update NextDueDate for recurring task
|
|
nextDue := completedAt.AddDate(0, 0, 7)
|
|
task.NextDueDate = &nextDue
|
|
db.Save(task)
|
|
|
|
// After completion - should be in due_soon, NOT completed
|
|
board, err = taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
|
|
for _, col := range board.Columns {
|
|
switch col.Name {
|
|
case "overdue_tasks":
|
|
overdueCount = col.Count
|
|
case "completed_tasks":
|
|
completedCount = col.Count
|
|
case "due_soon_tasks":
|
|
dueSoonCount = col.Count
|
|
}
|
|
}
|
|
assert.Equal(t, 0, overdueCount, "Should no longer be overdue")
|
|
assert.Equal(t, 0, completedCount, "Should NOT be in completed (recurring task)")
|
|
assert.Equal(t, 1, dueSoonCount, "Should be in due_soon (next occurrence)")
|
|
}
|
|
|
|
// ============================================================================
|
|
// EDGE CASE TESTS
|
|
// ============================================================================
|
|
|
|
func TestEdgeCase_TaskDueExactlyAtThreshold(t *testing.T) {
|
|
// Test with 29 days which is clearly within threshold (not at boundary)
|
|
// to avoid timing-dependent boundary behavior
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(29)), // 29 days from now, within 30-day threshold
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
// 29 days is clearly within the 30-day threshold, so should be "due_soon"
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"Task due within threshold should be in due_soon")
|
|
|
|
// Also test that 31 days (clearly beyond threshold) is upcoming
|
|
taskBeyond := &models.Task{
|
|
NextDueDate: ptr(daysFromNow(31)), // 31 days from now, beyond 30-day threshold
|
|
}
|
|
columnBeyond := responses.DetermineKanbanColumn(taskBeyond, 30)
|
|
assert.Equal(t, "upcoming_tasks", columnBeyond,
|
|
"Task due beyond threshold should be upcoming")
|
|
}
|
|
|
|
func TestEdgeCase_TaskDueJustBeforeThreshold(t *testing.T) {
|
|
// 29 days and 23 hours from now
|
|
dueDate := time.Now().UTC().Add(29*24*time.Hour + 23*time.Hour)
|
|
task := &models.Task{
|
|
NextDueDate: &dueDate,
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"Task due just before threshold should be in due_soon")
|
|
}
|
|
|
|
func TestEdgeCase_TaskDueInPast_ButHasCompletionAfter(t *testing.T) {
|
|
// One-time task that was overdue but got completed late
|
|
// NextDueDate should be nil after completion
|
|
task := &models.Task{
|
|
DueDate: ptr(daysAgo(5)),
|
|
NextDueDate: nil, // Set to nil after completion
|
|
Completions: []models.TaskCompletion{
|
|
{CompletedAt: daysAgo(1)}, // Completed 4 days late
|
|
},
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "completed_tasks", column,
|
|
"Late-completed one-time task should be in completed")
|
|
}
|
|
|
|
func TestEdgeCase_CancelledAndOverdue(t *testing.T) {
|
|
task := &models.Task{
|
|
IsCancelled: true,
|
|
NextDueDate: ptr(daysAgo(10)),
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "cancelled_tasks", column,
|
|
"Cancelled takes precedence over overdue")
|
|
}
|
|
|
|
func TestEdgeCase_InProgressAndOverdue(t *testing.T) {
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysAgo(5)),
|
|
InProgress: true,
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
|
|
assert.Equal(t, "in_progress_tasks", column,
|
|
"In Progress takes precedence over overdue")
|
|
}
|
|
|
|
func TestEdgeCase_MonthlyRecurringTask(t *testing.T) {
|
|
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")
|
|
|
|
// Get "Monthly" frequency (30 days)
|
|
var monthlyFreq models.TaskFrequency
|
|
db.Where("name = ?", "Monthly").First(&monthlyFreq)
|
|
require.NotNil(t, monthlyFreq.Days)
|
|
require.Equal(t, 30, *monthlyFreq.Days)
|
|
|
|
dueDate := daysAgo(2) // Overdue
|
|
task := &models.Task{
|
|
ResidenceID: residence.ID,
|
|
CreatedByID: user.ID,
|
|
Title: "Monthly Task",
|
|
DueDate: &dueDate,
|
|
NextDueDate: &dueDate,
|
|
FrequencyID: &monthlyFreq.ID,
|
|
}
|
|
db.Create(task)
|
|
|
|
// Complete the task
|
|
completedAt := time.Now().UTC()
|
|
db.Create(&models.TaskCompletion{
|
|
TaskID: task.ID,
|
|
CompletedByID: user.ID,
|
|
CompletedAt: completedAt,
|
|
})
|
|
|
|
// Update NextDueDate
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
nextDue := completedAt.AddDate(0, 0, 30) // 30 days from now
|
|
task.NextDueDate = &nextDue
|
|
db.Save(task)
|
|
|
|
task, _ = taskRepo.FindByID(task.ID)
|
|
|
|
// 30 days from now is at/within threshold boundary - due to time precision,
|
|
// a task at exactly the threshold boundary is considered "due_soon" not "upcoming"
|
|
// because the check is NextDueDate.Before(threshold) which includes boundary due to ms precision
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "due_soon_tasks", column,
|
|
"Monthly task at 30-day boundary should be due_soon (at threshold)")
|
|
}
|
|
|
|
func TestEdgeCase_ZeroDayFrequency_TreatedAsOneTime(t *testing.T) {
|
|
// Some systems might have frequency.Days = 0 instead of nil for one-time
|
|
zeroDays := 0
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
Frequency: &models.TaskFrequency{Days: &zeroDays},
|
|
Completions: []models.TaskCompletion{
|
|
{CompletedAt: daysAgo(1)},
|
|
},
|
|
}
|
|
|
|
// Should be treated as completed (one-time task logic)
|
|
assert.True(t, isTaskCompleted(task),
|
|
"Task with 0-day frequency and nil NextDueDate should be completed")
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "completed_tasks", column)
|
|
}
|
|
|
|
// ============================================================================
|
|
// BUTTON TYPES CONSISTENCY TESTS
|
|
// ============================================================================
|
|
|
|
func TestButtonTypes_ConsistencyWithKanbanColumn(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expectedColumn string
|
|
expectedButtons []string
|
|
}{
|
|
{
|
|
name: "Cancelled task",
|
|
task: &models.Task{
|
|
IsCancelled: true,
|
|
},
|
|
expectedColumn: "cancelled_tasks",
|
|
expectedButtons: []string{"uncancel", "delete"},
|
|
},
|
|
{
|
|
name: "Completed one-time task",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
|
},
|
|
expectedColumn: "completed_tasks",
|
|
expectedButtons: []string{}, // Read-only
|
|
},
|
|
{
|
|
name: "In Progress task",
|
|
task: &models.Task{
|
|
NextDueDate: ptr(daysFromNow(10)),
|
|
InProgress: true,
|
|
},
|
|
expectedColumn: "in_progress_tasks",
|
|
expectedButtons: []string{"edit", "complete", "cancel"},
|
|
},
|
|
{
|
|
name: "Overdue task",
|
|
task: &models.Task{
|
|
NextDueDate: ptr(daysAgo(5)),
|
|
},
|
|
expectedColumn: "overdue_tasks",
|
|
expectedButtons: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
|
},
|
|
{
|
|
name: "Due soon task",
|
|
task: &models.Task{
|
|
NextDueDate: ptr(daysFromNow(15)),
|
|
},
|
|
expectedColumn: "due_soon_tasks",
|
|
expectedButtons: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
|
},
|
|
{
|
|
name: "Upcoming task",
|
|
task: &models.Task{
|
|
NextDueDate: ptr(daysFromNow(45)),
|
|
},
|
|
expectedColumn: "upcoming_tasks",
|
|
expectedButtons: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
column := responses.DetermineKanbanColumn(tt.task, 30)
|
|
buttons := GetButtonTypesForTask(tt.task, 30)
|
|
|
|
assert.Equal(t, tt.expectedColumn, column, "Column mismatch")
|
|
assert.ElementsMatch(t, tt.expectedButtons, buttons, "Button types mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PRIORITY ORDER TESTS
|
|
// ============================================================================
|
|
|
|
func TestPriorityOrder_CancelledBeatsEverything(t *testing.T) {
|
|
// Task that is cancelled, overdue, in progress, with completions
|
|
task := &models.Task{
|
|
IsCancelled: true,
|
|
NextDueDate: ptr(daysAgo(10)),
|
|
InProgress: true,
|
|
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "cancelled_tasks", column)
|
|
}
|
|
|
|
func TestPriorityOrder_CompletedBeatsInProgress(t *testing.T) {
|
|
// One-time task with In Progress status but completed
|
|
task := &models.Task{
|
|
NextDueDate: nil,
|
|
InProgress: true,
|
|
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "completed_tasks", column)
|
|
}
|
|
|
|
func TestPriorityOrder_InProgressBeatsDateBased(t *testing.T) {
|
|
// Overdue task that's in progress
|
|
task := &models.Task{
|
|
NextDueDate: ptr(daysAgo(10)),
|
|
InProgress: true,
|
|
}
|
|
|
|
column := responses.DetermineKanbanColumn(task, 30)
|
|
assert.Equal(t, "in_progress_tasks", column)
|
|
}
|