- Webhook event logging repo and subscription webhook idempotency - Pagination helper (echohelpers) with cursor/offset support - Request ID and structured logging middleware - Push client improvements (FCM HTTP v1, better error handling) - Task model version column, business constraint migrations, targeted indexes - Expanded categorization chain tests - Email service and config hardening - CI workflow updates, .gitignore additions, .env.example updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2100 lines
69 KiB
Go
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, task.Version)
|
|
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, task.Version)
|
|
err := repo.Uncancel(task.ID, task.Version+1) // version incremented by Cancel
|
|
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, task.Version)
|
|
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, task.Version)
|
|
err := repo.Unarchive(task.ID, task.Version+1) // version incremented by Archive
|
|
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, task.Version)
|
|
|
|
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, archivedTask.Version)
|
|
|
|
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, cancelledTask.Version)
|
|
|
|
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")
|
|
}
|