Files
honeyDueAPI/internal/repositories/task_repo_test.go
Trey t 6dac34e373 Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:52:08 -06:00

1981 lines
64 KiB
Go

package repositories
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/testutil"
)
func TestTaskRepository_Create(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := 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: "Fix leaky faucet",
Description: "Kitchen faucet is dripping",
}
err := repo.Create(task)
require.NoError(t, err)
assert.NotZero(t, task.ID)
}
func TestTaskRepository_FindByID(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, found.ID)
assert.Equal(t, "Test Task", found.Title)
}
func TestTaskRepository_FindByID_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
_, err := repo.FindByID(9999)
assert.Error(t, err)
}
func TestTaskRepository_FindByResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence2.ID, user.ID, "Task 3")
tasks, err := repo.FindByResidence(residence1.ID)
require.NoError(t, err)
assert.Len(t, tasks, 2)
}
func TestTaskRepository_Update(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Original Title")
task.Title = "Updated Title"
task.Description = "Updated description"
err := repo.Update(task)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.Equal(t, "Updated Title", found.Title)
assert.Equal(t, "Updated description", found.Description)
}
func TestTaskRepository_Delete(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
err := repo.Delete(task.ID)
require.NoError(t, err)
_, err = repo.FindByID(task.ID)
assert.Error(t, err) // Should not be found
}
func TestTaskRepository_Cancel(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
assert.False(t, task.IsCancelled)
err := repo.Cancel(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.True(t, found.IsCancelled)
}
func TestTaskRepository_Uncancel(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
repo.Cancel(task.ID)
err := repo.Uncancel(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.False(t, found.IsCancelled)
}
func TestTaskRepository_Archive(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
err := repo.Archive(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.True(t, found.IsArchived)
}
func TestTaskRepository_Unarchive(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
repo.Archive(task.ID)
err := repo.Unarchive(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.False(t, found.IsArchived)
}
func TestTaskRepository_CreateCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Completed successfully",
}
err := repo.CreateCompletion(completion)
require.NoError(t, err)
assert.NotZero(t, completion.ID)
}
func TestTaskRepository_FindCompletionByID(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Test notes",
}
db.Create(completion)
found, err := repo.FindCompletionByID(completion.ID)
require.NoError(t, err)
assert.Equal(t, completion.ID, found.ID)
assert.Equal(t, "Test notes", found.Notes)
}
func TestTaskRepository_FindCompletionsByTask(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
// Create multiple completions
for i := 0; i < 3; i++ {
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
})
}
completions, err := repo.FindCompletionsByTask(task.ID)
require.NoError(t, err)
assert.Len(t, completions, 3)
}
func TestTaskRepository_DeleteCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
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, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
}
db.Create(completion)
err := repo.DeleteCompletion(completion.ID)
require.NoError(t, err)
_, err = repo.FindCompletionByID(completion.ID)
assert.Error(t, err)
}
func TestTaskRepository_GetAllCategories(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
categories, err := repo.GetAllCategories()
require.NoError(t, err)
assert.Greater(t, len(categories), 0)
}
func TestTaskRepository_GetAllPriorities(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
priorities, err := repo.GetAllPriorities()
require.NoError(t, err)
assert.Greater(t, len(priorities), 0)
}
func TestTaskRepository_GetAllFrequencies(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
frequencies, err := repo.GetAllFrequencies()
require.NoError(t, err)
assert.Greater(t, len(frequencies), 0)
}
func TestTaskRepository_CountByResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
count, err := repo.CountByResidence(residence.ID)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
}
// === Kanban Board Categorization Tests ===
func TestKanbanBoard_CancelledTasksGoToCancelledColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a cancelled task
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Cancelled Task")
repo.Cancel(task.ID)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find cancelled column
var cancelledColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
break
}
}
require.NotNil(t, cancelledColumn, "cancelled_tasks column should exist")
assert.Equal(t, 1, cancelledColumn.Count)
assert.Len(t, cancelledColumn.Tasks, 1)
assert.Equal(t, "Cancelled Task", cancelledColumn.Tasks[0].Title)
// Verify button types for cancelled column
assert.ElementsMatch(t, []string{"uncancel", "delete"}, cancelledColumn.ButtonTypes)
}
func TestKanbanBoard_CompletedTasksGoToCompletedColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with a completion
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Completed Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Done!",
}
err := repo.CreateCompletion(completion)
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find completed column
var completedColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "completed_tasks" {
completedColumn = &board.Columns[i]
break
}
}
require.NotNil(t, completedColumn, "completed_tasks column should exist")
assert.Equal(t, 1, completedColumn.Count)
assert.Len(t, completedColumn.Tasks, 1)
assert.Equal(t, "Completed Task", completedColumn.Tasks[0].Title)
// Verify button types for completed column (read-only, no buttons)
assert.ElementsMatch(t, []string{}, completedColumn.ButtonTypes)
}
func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with InProgress = true
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "In Progress Task",
InProgress: true,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find in_progress column
var inProgressColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "in_progress_tasks" {
inProgressColumn = &board.Columns[i]
break
}
}
require.NotNil(t, inProgressColumn, "in_progress_tasks column should exist")
assert.Equal(t, 1, inProgressColumn.Count)
assert.Len(t, inProgressColumn.Tasks, 1)
assert.Equal(t, "In Progress Task", inProgressColumn.Tasks[0].Title)
// Verify button types for in-progress column (no mark_in_progress since already in progress)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel"}, inProgressColumn.ButtonTypes)
}
func TestKanbanBoard_OverdueTasksGoToOverdueColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
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 in the past
pastDue := time.Now().UTC().AddDate(0, 0, -5) // 5 days ago
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue Task",
DueDate: &pastDue,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find overdue column
var overdueColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
break
}
}
require.NotNil(t, overdueColumn, "overdue_tasks column should exist")
assert.Equal(t, 1, overdueColumn.Count)
assert.Len(t, overdueColumn.Tasks, 1)
assert.Equal(t, "Overdue Task", overdueColumn.Tasks[0].Title)
// Verify button types for overdue column
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, overdueColumn.ButtonTypes)
}
func TestKanbanBoard_DueSoonTasksGoToDueSoonColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
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 within threshold (default 30 days)
dueSoon := time.Now().UTC().AddDate(0, 0, 15) // 15 days from now
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Due Soon Task",
DueDate: &dueSoon,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find due_soon column
var dueSoonColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "due_soon_tasks" {
dueSoonColumn = &board.Columns[i]
break
}
}
require.NotNil(t, dueSoonColumn, "due_soon_tasks column should exist")
assert.Equal(t, 1, dueSoonColumn.Count)
assert.Len(t, dueSoonColumn.Tasks, 1)
assert.Equal(t, "Due Soon Task", dueSoonColumn.Tasks[0].Title)
// Verify button types for due_soon column
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, dueSoonColumn.ButtonTypes)
}
func TestKanbanBoard_UpcomingTasksGoToUpcomingColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
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 beyond threshold (default 30 days)
upcoming := time.Now().UTC().AddDate(0, 0, 45) // 45 days from now
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Upcoming Task",
DueDate: &upcoming,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find upcoming column
var upcomingColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
break
}
}
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
assert.Equal(t, 1, upcomingColumn.Count)
assert.Len(t, upcomingColumn.Tasks, 1)
assert.Equal(t, "Upcoming Task", upcomingColumn.Tasks[0].Title)
// Verify button types for upcoming column
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, upcomingColumn.ButtonTypes)
}
func TestKanbanBoard_TasksWithNoDueDateGoToUpcomingColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with no due date
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "No Due Date Task")
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find upcoming column
var upcomingColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
break
}
}
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
assert.Equal(t, 1, upcomingColumn.Count)
assert.Equal(t, task.ID, upcomingColumn.Tasks[0].ID)
}
func TestKanbanBoard_ArchivedTasksGoToCancelledColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a regular task and an archived task
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Regular Task")
archivedTask := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Archived Task")
repo.Archive(archivedTask.ID)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find the cancelled column and verify archived task is there
var cancelledColumn *models.KanbanColumn
var upcomingColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
}
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
}
require.NotNil(t, cancelledColumn, "cancelled_tasks column should exist")
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
// Archived task should be in the cancelled column
assert.Equal(t, 1, cancelledColumn.Count, "archived task should be in cancelled column")
assert.Equal(t, "Archived Task", cancelledColumn.Tasks[0].Title)
// Regular task should be in upcoming (no due date)
assert.Equal(t, 1, upcomingColumn.Count, "regular task should be in upcoming column")
assert.Equal(t, "Regular Task", upcomingColumn.Tasks[0].Title)
// Total tasks should be 2 (both appear in the board)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 2, totalTasks, "both tasks should appear in the board")
}
func TestKanbanBoard_ArchivedOverdueTask_GoesToCancelledNotOverdue(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create an archived task that would be overdue if it weren't archived
archivedOverdueTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Archived Overdue Task",
DueDate: &pastDue,
IsArchived: true,
}
db.Create(archivedOverdueTask)
board, err := repo.GetKanbanData(residence.ID, 30, now)
require.NoError(t, err)
// Find columns
var cancelledColumn, overdueColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
require.NotNil(t, cancelledColumn)
require.NotNil(t, overdueColumn)
// Archived task should be in cancelled, NOT overdue
assert.Equal(t, 1, cancelledColumn.Count, "archived task should be in cancelled column")
assert.Equal(t, 0, overdueColumn.Count, "archived task should NOT be in overdue column")
assert.Equal(t, "Archived Overdue Task", cancelledColumn.Tasks[0].Title)
}
func TestKanbanBoard_CategoryPriority_CancelledTakesPrecedence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task that is BOTH overdue AND cancelled
// Cancelled should take precedence
pastDue := time.Now().UTC().AddDate(0, 0, -5)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue and Cancelled Task",
DueDate: &pastDue,
IsCancelled: true,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find cancelled column
var cancelledColumn *models.KanbanColumn
var overdueColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
// Task should be in cancelled, not overdue
assert.Equal(t, 1, cancelledColumn.Count, "Task should be in cancelled column")
assert.Equal(t, 0, overdueColumn.Count, "Task should NOT be in overdue column")
}
func TestKanbanBoard_CategoryPriority_CompletedTakesPrecedenceOverInProgress(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task that has InProgress = true AND a completion
// Completed should take precedence
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "In Progress with Completion",
InProgress: true,
}
err := db.Create(task).Error
require.NoError(t, err)
// Add a completion
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
}
err = repo.CreateCompletion(completion)
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find columns
var completedColumn, inProgressColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "completed_tasks" {
completedColumn = &board.Columns[i]
}
if board.Columns[i].Name == "in_progress_tasks" {
inProgressColumn = &board.Columns[i]
}
}
// Task should be in completed, not in_progress
assert.Equal(t, 1, completedColumn.Count, "Task should be in completed column")
assert.Equal(t, 0, inProgressColumn.Count, "Task should NOT be in in_progress column")
}
func TestKanbanBoard_DaysThresholdAffectsCategorization(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task due in 10 days
dueIn10Days := time.Now().UTC().AddDate(0, 0, 10)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Task Due in 10 Days",
DueDate: &dueIn10Days,
}
err := db.Create(task).Error
require.NoError(t, err)
// With 30-day threshold, should be in "due_soon"
board30, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
var dueSoon30, upcoming30 *models.KanbanColumn
for i := range board30.Columns {
if board30.Columns[i].Name == "due_soon_tasks" {
dueSoon30 = &board30.Columns[i]
}
if board30.Columns[i].Name == "upcoming_tasks" {
upcoming30 = &board30.Columns[i]
}
}
assert.Equal(t, 1, dueSoon30.Count, "With 30-day threshold, task should be in due_soon")
assert.Equal(t, 0, upcoming30.Count, "With 30-day threshold, task should NOT be in upcoming")
// With 5-day threshold, should be in "upcoming"
board5, err := repo.GetKanbanData(residence.ID, 5, time.Now().UTC())
require.NoError(t, err)
var dueSoon5, upcoming5 *models.KanbanColumn
for i := range board5.Columns {
if board5.Columns[i].Name == "due_soon_tasks" {
dueSoon5 = &board5.Columns[i]
}
if board5.Columns[i].Name == "upcoming_tasks" {
upcoming5 = &board5.Columns[i]
}
}
assert.Equal(t, 0, dueSoon5.Count, "With 5-day threshold, task should NOT be in due_soon")
assert.Equal(t, 1, upcoming5.Count, "With 5-day threshold, task should be in upcoming")
}
func TestKanbanBoard_ColumnMetadata(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Verify all 6 columns exist with correct metadata
expectedColumns := []struct {
name string
displayName string
color string
buttonTypes []string
iosIcon string
androidIcon string
}{
{"overdue_tasks", "Overdue", "#FF3B30", []string{"edit", "complete", "cancel", "mark_in_progress"}, "exclamationmark.triangle", "Warning"},
{"in_progress_tasks", "In Progress", "#5856D6", []string{"edit", "complete", "cancel"}, "hammer", "Build"},
{"due_soon_tasks", "Due Soon", "#FF9500", []string{"edit", "complete", "cancel", "mark_in_progress"}, "clock", "Schedule"},
{"upcoming_tasks", "Upcoming", "#007AFF", []string{"edit", "complete", "cancel", "mark_in_progress"}, "calendar", "Event"},
{"completed_tasks", "Completed", "#34C759", []string{}, "checkmark.circle", "CheckCircle"}, // Completed tasks are read-only (no buttons)
{"cancelled_tasks", "Cancelled", "#8E8E93", []string{"uncancel", "delete"}, "xmark.circle", "Cancel"},
}
assert.Len(t, board.Columns, 6, "Board should have 6 columns")
for i, expected := range expectedColumns {
col := board.Columns[i]
assert.Equal(t, expected.name, col.Name, "Column %d name mismatch", i)
assert.Equal(t, expected.displayName, col.DisplayName, "Column %d display name mismatch", i)
assert.Equal(t, expected.color, col.Color, "Column %d color mismatch", i)
assert.ElementsMatch(t, expected.buttonTypes, col.ButtonTypes, "Column %d button types mismatch", i)
assert.Equal(t, expected.iosIcon, col.Icons["ios"], "Column %d iOS icon mismatch", i)
assert.Equal(t, expected.androidIcon, col.Icons["android"], "Column %d Android icon mismatch", i)
}
// Verify days threshold is returned
assert.Equal(t, 30, board.DaysThreshold)
}
func TestKanbanBoard_MultipleResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
// Create tasks in both residences
testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task in House 1")
testutil.CreateTestTask(t, db, residence2.ID, user.ID, "Task in House 2")
// Create a cancelled task in house 1
cancelledTask := testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Cancelled in House 1")
repo.Cancel(cancelledTask.ID)
board, err := repo.GetKanbanDataForMultipleResidences([]uint{residence1.ID, residence2.ID}, 30, time.Now().UTC())
require.NoError(t, err)
// Count total tasks
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 3, totalTasks, "Should have 3 tasks total across both residences")
// Find upcoming and cancelled columns
var upcomingColumn, cancelledColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
}
}
assert.Equal(t, 2, upcomingColumn.Count, "Should have 2 upcoming tasks")
assert.Equal(t, 1, cancelledColumn.Count, "Should have 1 cancelled task")
}
// === Single-Purpose Function Tests ===
func TestGetOverdueTasks_Basic(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create an overdue task
overdueTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue Task",
DueDate: &pastDue,
}
db.Create(overdueTask)
// Create a task due in the future (not overdue)
futureDue := now.AddDate(0, 0, 10)
futureTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Future Task",
DueDate: &futureDue,
}
db.Create(futureTask)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Overdue Task", tasks[0].Title)
}
func TestGetOverdueTasks_ExcludesInProgressByDefault(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create an overdue task that is in-progress
inProgressTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue In Progress",
DueDate: &pastDue,
InProgress: true,
}
db.Create(inProgressTask)
// Create a regular overdue task
regularTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue Not In Progress",
DueDate: &pastDue,
}
db.Create(regularTask)
// Default: excludes in-progress
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Overdue Not In Progress", tasks[0].Title)
}
func TestGetOverdueTasks_IncludesInProgressWhenRequested(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create an overdue task that is in-progress
inProgressTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue In Progress",
DueDate: &pastDue,
InProgress: true,
}
db.Create(inProgressTask)
// Create a regular overdue task
regularTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue Not In Progress",
DueDate: &pastDue,
}
db.Create(regularTask)
// With IncludeInProgress=true: includes both
opts := TaskFilterOptions{
ResidenceID: residence.ID,
IncludeInProgress: true,
}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 2)
}
func TestGetOverdueTasks_TaskDueToday_NotOverdue(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Use start of today for "now"
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
// Create a task due TODAY
taskDueToday := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Task Due Today",
DueDate: &startOfToday,
}
db.Create(taskDueToday)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
// Task due TODAY is NOT overdue - it becomes overdue tomorrow
assert.Len(t, tasks, 0, "Task due today should NOT be overdue")
}
func TestGetDueSoonTasks_Basic(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
dueSoon := now.AddDate(0, 0, 5)
dueLater := now.AddDate(0, 0, 45)
// Create a task due soon (within 30 days)
dueSoonTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Due Soon Task",
DueDate: &dueSoon,
}
db.Create(dueSoonTask)
// Create a task due later (beyond 30 days)
dueLaterTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Due Later Task",
DueDate: &dueLater,
}
db.Create(dueLaterTask)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetDueSoonTasks(now, 30, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Due Soon Task", tasks[0].Title)
}
func TestGetDueSoonTasks_TaskDueToday_IncludedInDueSoon(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
// Create a task due TODAY
taskDueToday := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Task Due Today",
DueDate: &startOfToday,
}
db.Create(taskDueToday)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetDueSoonTasks(now, 30, opts)
require.NoError(t, err)
// Task due TODAY should be in "due soon" (not overdue)
assert.Len(t, tasks, 1, "Task due today should be in due_soon")
assert.Equal(t, "Task Due Today", tasks[0].Title)
}
func TestGetInProgressTasks_Basic(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create an in-progress task
inProgressTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "In Progress Task",
InProgress: true,
}
db.Create(inProgressTask)
// Create a regular task (not in-progress)
regularTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Regular Task",
}
db.Create(regularTask)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetInProgressTasks(opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "In Progress Task", tasks[0].Title)
}
func TestGetCompletedTasks_Basic(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with a completion (completed)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Completed Task",
}
db.Create(task)
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
})
// Create a task without completion (not completed)
notCompletedTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Not Completed Task",
}
db.Create(notCompletedTask)
opts := TaskFilterOptions{ResidenceID: residence.ID, PreloadCompletions: true}
tasks, err := repo.GetCompletedTasks(opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Completed Task", tasks[0].Title)
}
func TestGetCancelledTasks_Basic(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a cancelled task
cancelledTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Cancelled Task",
IsCancelled: true,
}
db.Create(cancelledTask)
// Create a regular task
regularTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Regular Task",
}
db.Create(regularTask)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetCancelledTasks(opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Cancelled Task", tasks[0].Title)
}
func TestGetUpcomingTasks_Basic(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
upcomingDue := now.AddDate(0, 0, 45) // Beyond 30-day threshold
dueSoonDue := now.AddDate(0, 0, 10) // Within 30-day threshold
// Create an upcoming task
upcomingTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Upcoming Task",
DueDate: &upcomingDue,
}
db.Create(upcomingTask)
// Create a due soon task
dueSoonTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Due Soon Task",
DueDate: &dueSoonDue,
}
db.Create(dueSoonTask)
// Create a task with no due date (should be upcoming)
noDueDateTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "No Due Date Task",
}
db.Create(noDueDateTask)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetUpcomingTasks(now, 30, opts)
require.NoError(t, err)
assert.Len(t, tasks, 2) // Upcoming + No due date
titles := []string{tasks[0].Title, tasks[1].Title}
assert.Contains(t, titles, "Upcoming Task")
assert.Contains(t, titles, "No Due Date Task")
}
// === TaskFilterOptions Tests ===
func TestTaskFilterOptions_SingleResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create overdue tasks in both residences
db.Create(&models.Task{ResidenceID: residence1.ID, CreatedByID: user.ID, Title: "Overdue in House 1", DueDate: &pastDue})
db.Create(&models.Task{ResidenceID: residence2.ID, CreatedByID: user.ID, Title: "Overdue in House 2", DueDate: &pastDue})
opts := TaskFilterOptions{ResidenceID: residence1.ID}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Overdue in House 1", tasks[0].Title)
}
func TestTaskFilterOptions_MultipleResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
residence3 := testutil.CreateTestResidence(t, db, user.ID, "House 3")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create overdue tasks in all three residences
db.Create(&models.Task{ResidenceID: residence1.ID, CreatedByID: user.ID, Title: "Overdue in House 1", DueDate: &pastDue})
db.Create(&models.Task{ResidenceID: residence2.ID, CreatedByID: user.ID, Title: "Overdue in House 2", DueDate: &pastDue})
db.Create(&models.Task{ResidenceID: residence3.ID, CreatedByID: user.ID, Title: "Overdue in House 3", DueDate: &pastDue})
// Filter for residences 1 and 2 only
opts := TaskFilterOptions{ResidenceIDs: []uint{residence1.ID, residence2.ID}}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 2)
}
func TestTaskFilterOptions_UserIDs(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user1 := testutil.CreateTestUser(t, db, "owner1", "owner1@test.com", "password")
user2 := testutil.CreateTestUser(t, db, "owner2", "owner2@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user1.ID, "User1 House")
residence2 := testutil.CreateTestResidence(t, db, user2.ID, "User2 House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create overdue tasks in both users' residences
db.Create(&models.Task{ResidenceID: residence1.ID, CreatedByID: user1.ID, Title: "User1 Overdue", DueDate: &pastDue})
db.Create(&models.Task{ResidenceID: residence2.ID, CreatedByID: user2.ID, Title: "User2 Overdue", DueDate: &pastDue})
// Filter by user1 only
opts := TaskFilterOptions{UserIDs: []uint{user1.ID}, IncludeInProgress: true}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "User1 Overdue", tasks[0].Title)
}
func TestTaskFilterOptions_IncludeArchived(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create an overdue task
activeTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Active Overdue",
DueDate: &pastDue,
}
db.Create(activeTask)
// Create an archived overdue task
archivedTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Archived Overdue",
DueDate: &pastDue,
IsArchived: true,
}
db.Create(archivedTask)
// Default: excludes archived
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1)
assert.Equal(t, "Active Overdue", tasks[0].Title)
// With IncludeArchived: includes both
opts = TaskFilterOptions{ResidenceID: residence.ID, IncludeArchived: true}
tasks, err = repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
assert.Len(t, tasks, 2)
}
// === Timezone Tests ===
func TestGetOverdueTasks_Timezone_TaskBecomesOverdueAtMidnight(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Task due date: Dec 15
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Test Task",
DueDate: &dueDate,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
// On Dec 15 at any time: NOT overdue
dec15Morning := time.Date(2025, 12, 15, 8, 0, 0, 0, time.UTC)
tasks, err := repo.GetOverdueTasks(dec15Morning, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15")
// On Dec 16 at midnight: IS overdue
dec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
tasks, err = repo.GetOverdueTasks(dec16Midnight, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task should be overdue on Dec 16")
}
func TestGetOverdueTasks_RecurringTask_UsesNextDueDate(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
originalDue := now.AddDate(0, 0, -30) // Original due date: 30 days ago
nextDue := now.AddDate(0, 0, 5) // Next due date: 5 days from now
// Create a recurring task that was completed, with NextDueDate in the future
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Recurring Task",
DueDate: &originalDue,
NextDueDate: &nextDue,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
// Task should NOT be overdue because NextDueDate (effective date) is in the future
assert.Len(t, tasks, 0, "Recurring task with future NextDueDate should NOT be overdue")
}
// === Consistency Tests ===
func TestKanbanBoardMatchesSinglePurposeFunctions(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
dueSoon := now.AddDate(0, 0, 10)
upcomingDue := now.AddDate(0, 0, 45)
// Create various tasks
db.Create(&models.Task{ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Overdue", DueDate: &pastDue})
db.Create(&models.Task{ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Due Soon", DueDate: &dueSoon})
db.Create(&models.Task{ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Upcoming", DueDate: &upcomingDue})
db.Create(&models.Task{ResidenceID: residence.ID, CreatedByID: user.ID, Title: "In Progress", InProgress: true})
db.Create(&models.Task{ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Cancelled", IsCancelled: true})
// Create a completed task
completedTask := &models.Task{ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Completed"}
db.Create(completedTask)
db.Create(&models.TaskCompletion{TaskID: completedTask.ID, CompletedByID: user.ID, CompletedAt: now})
opts := TaskFilterOptions{ResidenceID: residence.ID, PreloadCompletions: true}
// Get tasks using single-purpose functions
overdue, _ := repo.GetOverdueTasks(now, opts)
dueSoonTasks, _ := repo.GetDueSoonTasks(now, 30, opts)
inProgress, _ := repo.GetInProgressTasks(opts)
upcoming, _ := repo.GetUpcomingTasks(now, 30, opts)
completed, _ := repo.GetCompletedTasks(opts)
cancelled, _ := repo.GetCancelledTasks(opts)
// Get kanban board
board, err := repo.GetKanbanData(residence.ID, 30, now)
require.NoError(t, err)
// Compare counts
var boardOverdue, boardDueSoon, boardInProgress, boardUpcoming, boardCompleted, boardCancelled int
for _, col := range board.Columns {
switch col.Name {
case "overdue_tasks":
boardOverdue = col.Count
case "due_soon_tasks":
boardDueSoon = col.Count
case "in_progress_tasks":
boardInProgress = col.Count
case "upcoming_tasks":
boardUpcoming = col.Count
case "completed_tasks":
boardCompleted = col.Count
case "cancelled_tasks":
boardCancelled = col.Count
}
}
assert.Equal(t, len(overdue), boardOverdue, "Overdue count mismatch")
assert.Equal(t, len(dueSoonTasks), boardDueSoon, "Due Soon count mismatch")
assert.Equal(t, len(inProgress), boardInProgress, "In Progress count mismatch")
assert.Equal(t, len(upcoming), boardUpcoming, "Upcoming count mismatch")
assert.Equal(t, len(completed), boardCompleted, "Completed count mismatch")
assert.Equal(t, len(cancelled), boardCancelled, "Cancelled count mismatch")
}
// === Additional Timezone Tests ===
//
// Note: These tests verify that the `now` parameter's timezone affects
// when "start of day" is calculated. Due dates are stored in UTC, but the
// scope computes startOfDay using the timezone of the `now` parameter.
// This allows users in different timezones to see correct overdue status
// based on their local date.
func TestGetOverdueTasks_Timezone_Tokyo(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
tokyo, _ := time.LoadLocation("Asia/Tokyo")
// Task due on Dec 15 at midnight in Tokyo timezone
// When stored as UTC, this becomes Dec 14 15:00 UTC
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, tokyo)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Test Task",
DueDate: &dueDate,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
// When it's Dec 15 23:00 in Tokyo, task should NOT be overdue (still Dec 15)
tokyoDec15Evening := time.Date(2025, 12, 15, 23, 0, 0, 0, tokyo)
tasks, err := repo.GetOverdueTasks(tokyoDec15Evening, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15 in Tokyo timezone")
// When it's Dec 16 00:00 in Tokyo, task IS overdue (now Dec 16)
tokyoDec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, tokyo)
tasks, err = repo.GetOverdueTasks(tokyoDec16Midnight, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task should be overdue on Dec 16 in Tokyo timezone")
}
func TestGetOverdueTasks_Timezone_NewYork(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
newYork, _ := time.LoadLocation("America/New_York")
// Task due on Dec 15 at midnight in New York timezone
// When stored as UTC, this becomes Dec 15 05:00 UTC (EST is UTC-5)
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, newYork)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Test Task",
DueDate: &dueDate,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
// When it's Dec 15 23:00 in New York, task should NOT be overdue
nyDec15Evening := time.Date(2025, 12, 15, 23, 0, 0, 0, newYork)
tasks, err := repo.GetOverdueTasks(nyDec15Evening, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15 in New York timezone")
// When it's Dec 16 00:00 in New York, task IS overdue
nyDec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, newYork)
tasks, err = repo.GetOverdueTasks(nyDec16Midnight, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task should be overdue on Dec 16 in New York timezone")
}
func TestGetOverdueTasks_Timezone_InternationalDateLine(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Auckland is UTC+13, Honolulu is UTC-10
// They're 23 hours apart in local time
auckland, _ := time.LoadLocation("Pacific/Auckland")
honolulu, _ := time.LoadLocation("Pacific/Honolulu")
// Task due on Dec 15 at midnight Auckland time
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, auckland)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Test Task",
DueDate: &dueDate,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
// From Auckland's perspective on Dec 16, task is overdue
aucklandDec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, auckland)
tasks, err := repo.GetOverdueTasks(aucklandDec16, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task should be overdue on Dec 16 in Auckland")
// From Honolulu's perspective on Dec 14 (same UTC instant as Auckland Dec 15 morning),
// it's still before the due date, so NOT overdue
honoluluDec14 := time.Date(2025, 12, 14, 5, 0, 0, 0, honolulu)
tasks, err = repo.GetOverdueTasks(honoluluDec14, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 14 in Honolulu")
}
func TestGetDueSoonTasks_Timezone_DST(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
newYork, _ := time.LoadLocation("America/New_York")
// 2025 DST ends Nov 2: clocks fall back from 2:00 AM to 1:00 AM
// Task due on Nov 5 at midnight in New York timezone
dueDate := time.Date(2025, 11, 5, 0, 0, 0, 0, newYork)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Post-DST Task",
DueDate: &dueDate,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
// On Nov 3 (day after DST ends), with 7-day threshold, task should be due soon
// Nov 5 is 2 days from Nov 3, which is within 7-day threshold
nov3NY := time.Date(2025, 11, 3, 10, 0, 0, 0, newYork)
tasks, err := repo.GetDueSoonTasks(nov3NY, 7, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task due Nov 5 should be 'due soon' when checking from Nov 3 with 7-day threshold")
// With 2-day threshold: Nov 3 + 2 days = Nov 5 (at threshold boundary, exclusive)
// Task due Nov 5 is exactly AT the threshold, so should be EXCLUDED
tasks, err = repo.GetDueSoonTasks(nov3NY, 2, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task due Nov 5 should NOT be 'due soon' when checking from Nov 3 with 2-day threshold (at boundary)")
// With 3-day threshold: Nov 3 + 3 days = Nov 6 (task is before this)
// Task due Nov 5 should be INCLUDED
tasks, err = repo.GetDueSoonTasks(nov3NY, 3, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task due Nov 5 should be 'due soon' when checking from Nov 3 with 3-day threshold")
}
// === Additional Edge Case Tests ===
func TestGetDueSoonTasks_TaskDueAtThreshold_Excluded(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Fixed reference time
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
threshold := 7 // 7 days
// Task due exactly at threshold boundary (Dec 23 = Dec 16 + 7 days)
// The threshold is exclusive: due_soon means < (today + threshold)
// So Dec 23 is NOT included in due_soon when threshold is 7
atThreshold := time.Date(2025, 12, 23, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "At Threshold",
DueDate: &atThreshold,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetDueSoonTasks(now, threshold, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task due exactly at threshold boundary should be EXCLUDED (threshold is exclusive)")
}
func TestGetDueSoonTasks_TaskDueBeforeThreshold_Included(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Fixed reference time
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
threshold := 7 // 7 days
// Task due one day before threshold (Dec 22 = Dec 16 + 6 days)
beforeThreshold := time.Date(2025, 12, 22, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Before Threshold",
DueDate: &beforeThreshold,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetDueSoonTasks(now, threshold, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task due before threshold boundary should be INCLUDED")
assert.Equal(t, "Before Threshold", tasks[0].Title)
}
func TestGetDueSoonTasks_TaskDuePastThreshold_Excluded(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Fixed reference time
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
threshold := 7 // 7 days
// Task due after threshold (Dec 25 = Dec 16 + 9 days)
pastThreshold := time.Date(2025, 12, 25, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Past Threshold",
DueDate: &pastThreshold,
}
db.Create(task)
opts := TaskFilterOptions{ResidenceID: residence.ID}
tasks, err := repo.GetDueSoonTasks(now, threshold, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task due past threshold should be EXCLUDED from due soon")
// Verify it goes to upcoming instead
upcomingTasks, err := repo.GetUpcomingTasks(now, threshold, opts)
require.NoError(t, err)
assert.Len(t, upcomingTasks, 1, "Task due past threshold should be in UPCOMING")
}
func TestGetOverdueTasks_CompletedRecurring_NotOverdue(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
// Create a recurring task that was due Dec 10 (overdue), but has been completed
// and NextDueDate is set to Dec 20 (future)
originalDue := time.Date(2025, 12, 10, 0, 0, 0, 0, time.UTC)
nextDue := time.Date(2025, 12, 20, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Recurring Task",
DueDate: &originalDue,
NextDueDate: &nextDue, // Future date means it uses this for effective date
}
db.Create(task)
// Add a completion record
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Date(2025, 12, 12, 10, 0, 0, 0, time.UTC),
})
opts := TaskFilterOptions{ResidenceID: residence.ID, PreloadCompletions: true}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
// Task should NOT be overdue because NextDueDate (Dec 20) is in the future
assert.Len(t, tasks, 0, "Recurring task with future NextDueDate should NOT be overdue")
// Verify it's in due soon instead (Dec 20 is within 30 days)
dueSoonTasks, err := repo.GetDueSoonTasks(now, 30, opts)
require.NoError(t, err)
assert.Len(t, dueSoonTasks, 1, "Recurring task with future NextDueDate should be in DUE SOON")
}
func TestGetOverdueTasks_CompletedOneTime_NotOverdue(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
// Create a one-time task that was due Dec 10 (overdue) but has been completed
// NextDueDate is nil (one-time task)
originalDue := time.Date(2025, 12, 10, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Completed One-Time Task",
DueDate: &originalDue,
NextDueDate: nil, // One-time task
}
db.Create(task)
// Add a completion record
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Date(2025, 12, 12, 10, 0, 0, 0, time.UTC),
})
opts := TaskFilterOptions{ResidenceID: residence.ID, PreloadCompletions: true}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
// Task should NOT be overdue because it's completed (NextDueDate nil + has completion)
assert.Len(t, tasks, 0, "Completed one-time task should NOT be overdue")
// Verify it's in completed column
completedTasks, err := repo.GetCompletedTasks(opts)
require.NoError(t, err)
assert.Len(t, completedTasks, 1, "Completed one-time task should be in COMPLETED")
}
// === Consistency Tests ===
func TestConsistency_DueSoonPredicateVsScopeVsRepo(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
threshold := 30
// Create tasks with various due dates
testCases := []struct {
title string
daysFromNow int
expectDue bool
}{
{"Due Today", 0, true},
{"Due Tomorrow", 1, true},
{"Due in 15 days", 15, true},
{"Due in 29 days", 29, true},
{"Due at threshold (30 days)", 30, false}, // Threshold is exclusive
{"Due past threshold (31 days)", 31, false},
{"Overdue (-1 day)", -1, false},
}
for _, tc := range testCases {
dueDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, tc.daysFromNow)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: tc.title,
DueDate: &dueDate,
}
db.Create(task)
}
opts := TaskFilterOptions{ResidenceID: residence.ID, PreloadCompletions: true}
// Get due soon tasks from repository
repoTasks, err := repo.GetDueSoonTasks(now, threshold, opts)
require.NoError(t, err)
// Build map of task titles returned by repo
repoTaskTitles := make(map[string]bool)
for _, task := range repoTasks {
repoTaskTitles[task.Title] = true
}
// Verify each test case
for _, tc := range testCases {
found := repoTaskTitles[tc.title]
if tc.expectDue {
assert.True(t, found, "Task '%s' should be due soon but wasn't returned", tc.title)
} else {
assert.False(t, found, "Task '%s' should NOT be due soon but was returned", tc.title)
}
}
// Also verify count matches expected
expectedCount := 0
for _, tc := range testCases {
if tc.expectDue {
expectedCount++
}
}
assert.Equal(t, expectedCount, len(repoTasks), "Due soon task count mismatch")
}
func TestConsistency_OverduePredicateVsScopeVsRepo(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
now := time.Date(2025, 12, 16, 10, 0, 0, 0, time.UTC)
// Create tasks with various states
testCases := []struct {
title string
daysFromNow int
isCancelled bool
isArchived bool
hasCompletion bool
nextDueDate *time.Time
expectOverdue bool
}{
{"Overdue Yesterday", -1, false, false, false, nil, true},
{"Overdue Week Ago", -7, false, false, false, nil, true},
{"Due Today", 0, false, false, false, nil, false},
{"Due Tomorrow", 1, false, false, false, nil, false},
{"Overdue but Cancelled", -5, true, false, false, nil, false},
{"Overdue but Archived", -5, false, true, false, nil, false},
}
for _, tc := range testCases {
dueDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, tc.daysFromNow)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: tc.title,
DueDate: &dueDate,
IsCancelled: tc.isCancelled,
IsArchived: tc.isArchived,
NextDueDate: tc.nextDueDate,
}
db.Create(task)
if tc.hasCompletion {
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: now,
})
}
}
// Also add a completed one-time task (should NOT be overdue)
completedDue := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -3)
completedTask := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Completed One-Time Overdue",
DueDate: &completedDue,
NextDueDate: nil,
}
db.Create(completedTask)
db.Create(&models.TaskCompletion{
TaskID: completedTask.ID,
CompletedByID: user.ID,
CompletedAt: now,
})
opts := TaskFilterOptions{ResidenceID: residence.ID, PreloadCompletions: true}
// Get overdue tasks from repository
repoTasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
// Build map of task titles returned by repo
repoTaskTitles := make(map[string]bool)
for _, task := range repoTasks {
repoTaskTitles[task.Title] = true
}
// Verify each test case
for _, tc := range testCases {
found := repoTaskTitles[tc.title]
if tc.expectOverdue {
assert.True(t, found, "Task '%s' should be overdue but wasn't returned", tc.title)
} else {
assert.False(t, found, "Task '%s' should NOT be overdue but was returned", tc.title)
}
}
// Verify completed one-time task is not overdue
assert.False(t, repoTaskTitles["Completed One-Time Overdue"], "Completed one-time task should NOT be overdue")
// Also verify count matches expected
expectedCount := 0
for _, tc := range testCases {
if tc.expectOverdue {
expectedCount++
}
}
assert.Equal(t, expectedCount, len(repoTasks), "Overdue task count mismatch")
}