Files
honeyDueAPI/internal/repositories/task_repo_test.go
Trey t 215e7c895d wip
2026-02-18 10:54:18 -06:00

2100 lines
69 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_CancelledTasksHiddenFromKanbanBoard(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)
assert.Len(t, board.Columns, 5, "board should have 5 visible columns")
for _, col := range board.Columns {
assert.NotEqual(t, "cancelled_tasks", col.Name, "cancelled column must be hidden")
}
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 0, totalTasks, "cancelled task should be hidden from board")
}
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_ArchivedTasksHiddenFromKanbanBoard(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 upcoming column and verify archived task is hidden
var upcomingColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
foundCancelledColumn = true
}
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
}
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
// Archived task should be hidden from board
// 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 1 (archived task is hidden)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 1, totalTasks, "archived task should be hidden from board")
}
func TestKanbanBoard_ArchivedOverdueTask_HiddenFromKanbanBoard(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 overdueColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
foundCancelledColumn = true
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
require.NotNil(t, overdueColumn)
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
// Archived task should be hidden and NOT overdue
assert.Equal(t, 0, overdueColumn.Count, "archived task should NOT be in overdue column")
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 0, totalTasks, "archived task should be hidden from board")
}
func TestKanbanBoard_CategoryPriority_CancelledTasksAreHidden(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)
var overdueColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
foundCancelledColumn = true
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
// Cancelled task should be hidden and not appear as overdue
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
require.NotNil(t, overdueColumn, "overdue column should exist")
assert.Equal(t, 0, overdueColumn.Count, "Task should NOT be in overdue column")
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 0, totalTasks, "cancelled task should be hidden from board")
}
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 5 visible 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)
}
assert.Len(t, board.Columns, 5, "Board should have 5 visible 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 (cancelled task is intentionally hidden from board)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 2, totalTasks, "Should have 2 visible tasks total across both residences")
// Find upcoming column and ensure cancelled column is hidden
var upcomingColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
if board.Columns[i].Name == "cancelled_tasks" {
foundCancelledColumn = true
}
}
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
require.NotNil(t, upcomingColumn, "upcoming column should exist")
assert.Equal(t, 2, upcomingColumn.Count, "Should have 2 upcoming tasks")
}
// === 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)
}
// TestTaskFilterOptions_UserIDs_ExcludesInactiveResidences verifies that tasks
// from inactive residences are excluded when filtering by user IDs.
// This is a regression test for the bug where users received notifications
// for tasks on residences they had deactivated.
func TestTaskFilterOptions_UserIDs_ExcludesInactiveResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
// Create an active residence
activeResidence := testutil.CreateTestResidence(t, db, user.ID, "Active House")
// Create an inactive residence (manually, since testutil always creates active)
// Note: Must update after create because GORM's default:true overrides struct value
inactiveResidence := &models.Residence{
OwnerID: user.ID,
Name: "Inactive House",
StreetAddress: "456 Inactive St",
City: "Test City",
StateProvince: "TS",
PostalCode: "12345",
Country: "USA",
IsPrimary: false,
}
err := db.Create(inactiveResidence).Error
require.NoError(t, err)
// Set to inactive after creation to bypass GORM default
db.Model(inactiveResidence).Update("is_active", false)
now := time.Now().UTC()
pastDue := now.AddDate(0, 0, -5)
// Create overdue tasks in both residences
db.Create(&models.Task{
ResidenceID: activeResidence.ID,
CreatedByID: user.ID,
Title: "Overdue in Active Residence",
DueDate: &pastDue,
})
db.Create(&models.Task{
ResidenceID: inactiveResidence.ID,
CreatedByID: user.ID,
Title: "Overdue in Inactive Residence",
DueDate: &pastDue,
})
// Filter by user ID (used by notification system)
opts := TaskFilterOptions{UserIDs: []uint{user.ID}, IncludeInProgress: true}
tasks, err := repo.GetOverdueTasks(now, opts)
require.NoError(t, err)
// Should only return task from active residence
assert.Len(t, tasks, 1, "Should only return tasks from active residences")
assert.Equal(t, "Overdue in Active Residence", tasks[0].Title)
}
// TestTaskFilterOptions_UserIDs_DueSoon_ExcludesInactiveResidences verifies that
// due soon tasks from inactive residences are excluded when filtering by user IDs.
func TestTaskFilterOptions_UserIDs_DueSoon_ExcludesInactiveResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
// Create an active residence
activeResidence := testutil.CreateTestResidence(t, db, user.ID, "Active House")
// Create an inactive residence
// Note: Must update after create because GORM's default:true overrides struct value
inactiveResidence := &models.Residence{
OwnerID: user.ID,
Name: "Inactive House",
StreetAddress: "456 Inactive St",
City: "Test City",
StateProvince: "TS",
PostalCode: "12345",
Country: "USA",
IsPrimary: false,
}
err := db.Create(inactiveResidence).Error
require.NoError(t, err)
// Set to inactive after creation to bypass GORM default
db.Model(inactiveResidence).Update("is_active", false)
now := time.Now().UTC()
dueSoon := now.AddDate(0, 0, 5) // 5 days from now
// Create due soon tasks in both residences
db.Create(&models.Task{
ResidenceID: activeResidence.ID,
CreatedByID: user.ID,
Title: "Due Soon in Active Residence",
DueDate: &dueSoon,
})
db.Create(&models.Task{
ResidenceID: inactiveResidence.ID,
CreatedByID: user.ID,
Title: "Due Soon in Inactive Residence",
DueDate: &dueSoon,
})
// Filter by user ID (used by notification system)
opts := TaskFilterOptions{UserIDs: []uint{user.ID}, IncludeInProgress: true}
tasks, err := repo.GetDueSoonTasks(now, 30, opts)
require.NoError(t, err)
// Should only return task from active residence
assert.Len(t, tasks, 1, "Should only return tasks from active residences")
assert.Equal(t, "Due Soon in Active Residence", 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 int
foundCancelledColumn := false
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":
foundCancelledColumn = true
}
}
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.False(t, foundCancelledColumn, "cancelled column must be hidden")
assert.NotEmpty(t, cancelled, "single-purpose cancelled query should still return cancelled tasks")
}
// === 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")
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// A task "due on Dec 15" is stored as 2025-12-15 00:00:00 UTC.
// Timezone handling happens at query time via the `now` parameter.
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}
// When it's Dec 15 23:00 in Tokyo, task should NOT be overdue (still Dec 15 in Tokyo)
// The `now` parameter's date (Dec 15) is compared against the due date (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 in Tokyo, due Dec 15)
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")
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// A task "due on Dec 15" is stored as 2025-12-15 00:00:00 UTC.
// Timezone handling happens at query time via the `now` parameter.
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}
// When it's Dec 15 23:00 in New York, task should NOT be overdue (still Dec 15 in NY)
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 (now Dec 16 in NY, due Dec 15)
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")
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// Task due on Dec 15 is stored as 2025-12-15 00:00:00 UTC.
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}
// From Auckland's perspective on Dec 16, task is overdue (Dec 16 > Dec 15)
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, task is NOT overdue (Dec 14 < Dec 15)
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")
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// 2025 DST ends Nov 2: clocks fall back from 2:00 AM to 1:00 AM
// Task due on Nov 5 is stored as 2025-11-05 00:00:00 UTC
dueDate := time.Date(2025, 11, 5, 0, 0, 0, 0, time.UTC)
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")
}