Consolidate task logic into single source of truth (DRY refactor)

This refactor eliminates duplicate task logic across the codebase by
creating a centralized task package with three layers:

- predicates/: Pure Go functions defining task state logic (IsCompleted,
  IsOverdue, IsDueSoon, IsUpcoming, IsActive, IsInProgress, EffectiveDate)
- scopes/: GORM scope functions mirroring predicates for database queries
- categorization/: Chain of Responsibility pattern for kanban column assignment

Key fixes:
- Fixed PostgreSQL DATE vs TIMESTAMP comparison bug in scopes (added
  explicit ::timestamp casts) that caused summary/kanban count mismatches
- Fixed models/task.go IsOverdue() and IsDueSoon() to use EffectiveDate
  (NextDueDate ?? DueDate) instead of only DueDate
- Removed duplicate isTaskCompleted() helpers from task_repo.go and
  task_button_types.go

Files refactored to use consolidated logic:
- task_repo.go: Uses scopes for statistics, predicates for filtering
- task_button_types.go: Uses predicates instead of inline logic
- responses/task.go: Delegates to categorization package
- dashboard_handler.go: Uses scopes for task statistics
- residence_service.go: Uses predicates for report generation
- worker/jobs/handler.go: Documented SQL with predicate references

Added comprehensive tests:
- predicates_test.go: Unit tests for all predicate functions
- scopes_test.go: Integration tests verifying scopes match predicates
- consistency_test.go: Three-layer consistency tests ensuring predicates,
  scopes, and categorization all return identical results

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-07 11:48:03 -06:00
parent f0c7b070d7
commit cfb8a28870
16 changed files with 3408 additions and 679 deletions

View File

@@ -0,0 +1,274 @@
// Package scopes provides GORM scope functions that mirror the predicates.
// These scopes allow efficient database-level filtering using the same logic
// as the predicates in ../predicates/predicates.go.
//
// IMPORTANT: These scopes must produce the same results as their predicate counterparts.
// Any change to a predicate MUST be reflected in the corresponding scope.
// Tests verify consistency between predicates and scopes.
//
// Each scope includes a comment referencing its predicate counterpart for easy cross-reference.
package scopes
import (
"time"
"gorm.io/gorm"
)
// =============================================================================
// STATE SCOPES
// =============================================================================
// ScopeActive filters to tasks that are not cancelled and not archived.
// Active tasks are eligible for display in the kanban board.
//
// Predicate equivalent: IsActive(task)
//
// SQL: is_cancelled = false AND is_archived = false
func ScopeActive(db *gorm.DB) *gorm.DB {
return db.Where("is_cancelled = ? AND is_archived = ?", false, false)
}
// ScopeCancelled filters to cancelled tasks only.
//
// Predicate equivalent: IsCancelled(task)
//
// SQL: is_cancelled = true
func ScopeCancelled(db *gorm.DB) *gorm.DB {
return db.Where("is_cancelled = ?", true)
}
// ScopeArchived filters to archived tasks only.
//
// Predicate equivalent: IsArchived(task)
//
// SQL: is_archived = true
func ScopeArchived(db *gorm.DB) *gorm.DB {
return db.Where("is_archived = ?", true)
}
// ScopeCompleted filters to completed tasks.
//
// A task is completed when NextDueDate is nil AND it has at least one completion.
//
// Predicate equivalent: IsCompleted(task)
//
// SQL: next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func ScopeCompleted(db *gorm.DB) *gorm.DB {
return db.Where(
"next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
)
}
// ScopeNotCompleted excludes completed tasks.
//
// A task is NOT completed when it either has a NextDueDate OR has no completions.
//
// Predicate equivalent: !IsCompleted(task)
//
// SQL: NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
func ScopeNotCompleted(db *gorm.DB) *gorm.DB {
return db.Where(
"NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))",
)
}
// ScopeInProgress filters to tasks with status "In Progress".
//
// Predicate equivalent: IsInProgress(task)
//
// SQL: Joins task_taskstatus and filters by name = 'In Progress'
func ScopeInProgress(db *gorm.DB) *gorm.DB {
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("task_taskstatus.name = ?", "In Progress")
}
// ScopeNotInProgress excludes tasks with status "In Progress".
//
// Predicate equivalent: !IsInProgress(task)
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress")
}
// =============================================================================
// DATE SCOPES
// =============================================================================
// ScopeOverdue returns a scope for overdue tasks.
//
// A task is overdue when its effective date (COALESCE(next_due_date, due_date))
// is before the given time, and it's active and not completed.
//
// Predicate equivalent: IsOverdue(task, now)
//
// SQL: COALESCE(next_due_date, due_date) < ?::timestamp AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp because PostgreSQL DATE columns compared
// against string literals (which is how GORM passes time.Time) use date comparison,
// not timestamp comparison. For example:
// - '2025-12-07'::date < '2025-12-07 17:00:00' = false (compares dates only)
// - '2025-12-07'::date < '2025-12-07 17:00:00'::timestamp = true (compares timestamp)
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)
}
}
// ScopeDueSoon returns a scope for tasks due within the threshold.
//
// A task is "due soon" when its effective date is >= now AND < (now + threshold),
// and it's active and not completed.
//
// Predicate equivalent: IsDueSoon(task, now, daysThreshold)
//
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
//
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
// AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
threshold := now.AddDate(0, 0, daysThreshold)
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", now).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", threshold)
}
}
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
//
// A task is "upcoming" when its effective date is >= (now + threshold) OR is null,
// and it's active and not completed.
//
// Predicate equivalent: IsUpcoming(task, now, daysThreshold)
//
// SQL: (COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL))
//
// AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
threshold := now.AddDate(0, 0, daysThreshold)
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where(
"COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)",
threshold,
)
}
}
// ScopeDueInRange returns a scope for tasks with effective date in a range.
//
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
//
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", start).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", end)
}
}
// ScopeHasDueDate filters to tasks that have an effective due date.
//
// SQL: (next_due_date IS NOT NULL OR due_date IS NOT NULL)
func ScopeHasDueDate(db *gorm.DB) *gorm.DB {
return db.Where("next_due_date IS NOT NULL OR due_date IS NOT NULL")
}
// ScopeNoDueDate filters to tasks that have no effective due date.
//
// SQL: next_due_date IS NULL AND due_date IS NULL
func ScopeNoDueDate(db *gorm.DB) *gorm.DB {
return db.Where("next_due_date IS NULL AND due_date IS NULL")
}
// =============================================================================
// FILTER SCOPES
// =============================================================================
// ScopeForResidence filters tasks by a single residence ID.
//
// SQL: residence_id = ?
func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("residence_id = ?", residenceID)
}
}
// ScopeForResidences filters tasks by multiple residence IDs.
//
// SQL: residence_id IN (?)
func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if len(residenceIDs) == 0 {
// Return empty result if no residence IDs provided
return db.Where("1 = 0")
}
return db.Where("residence_id IN ?", residenceIDs)
}
}
// ScopeHasCompletions filters to tasks that have at least one completion.
//
// Predicate equivalent: HasCompletions(task)
//
// SQL: EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func ScopeHasCompletions(db *gorm.DB) *gorm.DB {
return db.Where(
"EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
)
}
// ScopeNoCompletions filters to tasks that have no completions.
//
// Predicate equivalent: !HasCompletions(task)
//
// SQL: NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func ScopeNoCompletions(db *gorm.DB) *gorm.DB {
return db.Where(
"NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
)
}
// =============================================================================
// ORDERING
// =============================================================================
// ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last.
//
// SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST
func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB {
return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST")
}
// ScopeOrderByPriority orders tasks by priority level descending (urgent first).
//
// SQL: ORDER BY priority_id DESC
func ScopeOrderByPriority(db *gorm.DB) *gorm.DB {
return db.Order("priority_id DESC")
}
// ScopeOrderByCreatedAt orders tasks by creation date descending (newest first).
//
// SQL: ORDER BY created_at DESC
func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC")
}
// ScopeKanbanOrder applies the standard kanban ordering.
//
// SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC
func ScopeKanbanOrder(db *gorm.DB) *gorm.DB {
return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC")
}

View File

@@ -0,0 +1,706 @@
package scopes_test
import (
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/predicates"
"github.com/treytartt/casera-api/internal/task/scopes"
)
// testDB holds the database connection for integration tests
var testDB *gorm.DB
// TestMain sets up the database connection for all tests in this package
func TestMain(m *testing.M) {
// Get database URL from environment or use default
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=mycrib_test port=5432 sslmode=disable"
}
var err error
testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
// Print message and skip tests if database is not available
println("Skipping scope integration tests: database not available")
println("Set TEST_DATABASE_URL to run these tests")
println("Error:", err.Error())
os.Exit(0)
}
// Verify connection works
sqlDB, err := testDB.DB()
if err != nil {
println("Failed to get underlying DB:", err.Error())
os.Exit(0)
}
if err := sqlDB.Ping(); err != nil {
println("Failed to ping database:", err.Error())
os.Exit(0)
}
println("Database connected successfully, running integration tests...")
// Run migrations for test tables
err = testDB.AutoMigrate(
&models.Task{},
&models.TaskCompletion{},
&models.TaskStatus{},
&models.Residence{},
)
if err != nil {
os.Exit(1)
}
// Run tests
code := m.Run()
// Cleanup
cleanupTestData()
os.Exit(code)
}
// cleanupTestData removes all test data
func cleanupTestData() {
if testDB == nil {
return
}
testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'test_%')")
testDB.Exec("DELETE FROM task_task WHERE title LIKE 'test_%'")
testDB.Exec("DELETE FROM task_taskstatus WHERE name LIKE 'test_%'")
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'")
}
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
}
// testUserID is a user ID that exists in the database for foreign key constraints
var testUserID uint = 1
// createTestResidence creates a test residence and returns its ID
func createTestResidence(t *testing.T) uint {
residence := &models.Residence{
Name: "test_residence_" + time.Now().Format("20060102150405"),
OwnerID: testUserID,
IsActive: true,
}
if err := testDB.Create(residence).Error; err != nil {
t.Fatalf("Failed to create test residence: %v", err)
}
return residence.ID
}
// createTestStatus creates a test status and returns it
func createTestStatus(t *testing.T, name string) *models.TaskStatus {
status := &models.TaskStatus{
Name: "test_" + name,
}
if err := testDB.Create(status).Error; err != nil {
t.Fatalf("Failed to create test status: %v", err)
}
return status
}
// createTestTask creates a task with the given properties
func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
task.ResidenceID = residenceID
task.Title = "test_" + task.Title
task.CreatedByID = testUserID // Required foreign key
if err := testDB.Create(task).Error; err != nil {
t.Fatalf("Failed to create test task: %v", err)
}
return task
}
// createTestCompletion creates a completion for a task
func createTestCompletion(t *testing.T, taskID uint) *models.TaskCompletion {
completion := &models.TaskCompletion{
TaskID: taskID,
CompletedByID: testUserID, // Required foreign key
CompletedAt: time.Now().UTC(),
}
if err := testDB.Create(completion).Error; err != nil {
t.Fatalf("Failed to create test completion: %v", err)
}
return completion
}
// TestScopeActiveMatchesPredicate verifies ScopeActive produces same results as IsActive
func TestScopeActiveMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// Create tasks with different active states
tasks := []*models.Task{
{Title: "active_task", IsCancelled: false, IsArchived: false},
{Title: "cancelled_task", IsCancelled: true, IsArchived: false},
{Title: "archived_task", IsCancelled: false, IsArchived: true},
{Title: "both_task", IsCancelled: true, IsArchived: true},
}
for _, task := range tasks {
createTestTask(t, residenceID, task)
}
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsActive(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeActive returned %d tasks, IsActive predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should only have the active task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 active task, got %d", len(scopeResults))
}
}
// TestScopeCompletedMatchesPredicate verifies ScopeCompleted produces same results as IsCompleted
func TestScopeCompletedMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
nextWeek := now.AddDate(0, 0, 7)
// Create tasks with different completion states
// Completed: NextDueDate nil AND has completions
completedTask := createTestTask(t, residenceID, &models.Task{
Title: "completed_task",
NextDueDate: nil,
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, completedTask.ID)
// Not completed: has completions but NextDueDate set (recurring)
recurringTask := createTestTask(t, residenceID, &models.Task{
Title: "recurring_with_completion",
NextDueDate: timePtr(nextWeek),
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, recurringTask.ID)
// Not completed: NextDueDate nil but no completions
createTestTask(t, residenceID, &models.Task{
Title: "no_completions",
NextDueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Not completed: has NextDueDate, no completions
createTestTask(t, residenceID, &models.Task{
Title: "pending_task",
NextDueDate: timePtr(nextWeek),
IsCancelled: false,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with completions preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsCompleted(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeCompleted returned %d tasks, IsCompleted predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should only have the completed task (nil NextDueDate + has completion)
if len(scopeResults) != 1 {
t.Errorf("Expected 1 completed task, got %d", len(scopeResults))
}
}
// TestScopeOverdueMatchesPredicate verifies ScopeOverdue produces same results as IsOverdue
func TestScopeOverdueMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
tomorrow := now.AddDate(0, 0, 1)
// Overdue: NextDueDate in past, active, not completed
createTestTask(t, residenceID, &models.Task{
Title: "overdue_task",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Overdue: DueDate in past (NextDueDate nil, no completions)
createTestTask(t, residenceID, &models.Task{
Title: "overdue_duedate",
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Not overdue: future date
createTestTask(t, residenceID, &models.Task{
Title: "future_task",
NextDueDate: timePtr(tomorrow),
IsCancelled: false,
IsArchived: false,
})
// Not overdue: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled_overdue",
NextDueDate: timePtr(yesterday),
IsCancelled: true,
IsArchived: false,
})
// Not overdue: completed (NextDueDate nil with completion)
completedTask := createTestTask(t, residenceID, &models.Task{
Title: "completed_past_due",
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, completedTask.ID)
// Not overdue: no due date
createTestTask(t, residenceID, &models.Task{
Title: "no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with completions preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeOverdue returned %d tasks, IsOverdue predicate returned %d tasks",
len(scopeResults), len(predicateResults))
t.Logf("Scope results: %v", getTaskTitles(scopeResults))
t.Logf("Predicate results: %v", getTaskTitles(predicateResults))
}
// Should have 2 overdue tasks
if len(scopeResults) != 2 {
t.Errorf("Expected 2 overdue tasks, got %d", len(scopeResults))
}
}
// TestScopeOverdueWithSameDayTask tests the DATE vs TIMESTAMP comparison edge case
// This is a regression test for the bug where tasks due "today" were not counted as overdue
func TestScopeOverdueWithSameDayTask(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// Create a task due at midnight today (simulating a DATE column)
now := time.Now().UTC()
todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
createTestTask(t, residenceID, &models.Task{
Title: "due_today_midnight",
NextDueDate: timePtr(todayMidnight),
IsCancelled: false,
IsArchived: false,
})
// Query using scope with current time (after midnight)
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateResults = append(predicateResults, task)
}
}
// Both should agree: if it's past midnight, the task due at midnight is overdue
if len(scopeResults) != len(predicateResults) {
t.Errorf("DATE vs TIMESTAMP mismatch! Scope returned %d, predicate returned %d",
len(scopeResults), len(predicateResults))
t.Logf("This indicates the PostgreSQL DATE/TIMESTAMP comparison bug may have returned")
}
// If current time is after midnight, task should be overdue
if now.After(todayMidnight) && len(scopeResults) != 1 {
t.Errorf("Task due at midnight should be overdue after midnight, got %d results", len(scopeResults))
}
}
// TestScopeDueSoonMatchesPredicate verifies ScopeDueSoon produces same results as IsDueSoon
func TestScopeDueSoonMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Due soon: within threshold
createTestTask(t, residenceID, &models.Task{
Title: "due_soon",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: beyond threshold
createTestTask(t, residenceID, &models.Task{
Title: "far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: overdue (in past)
createTestTask(t, residenceID, &models.Task{
Title: "overdue",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled",
NextDueDate: timePtr(in5Days),
IsCancelled: true,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsDueSoon(&task, now, daysThreshold) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeDueSoon returned %d tasks, IsDueSoon predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 1 due soon task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 due soon task, got %d", len(scopeResults))
}
}
// TestScopeUpcomingMatchesPredicate verifies ScopeUpcoming produces same results as IsUpcoming
func TestScopeUpcomingMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Upcoming: beyond threshold
createTestTask(t, residenceID, &models.Task{
Title: "far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
})
// Upcoming: no due date
createTestTask(t, residenceID, &models.Task{
Title: "no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Not upcoming: within due soon threshold
createTestTask(t, residenceID, &models.Task{
Title: "due_soon",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
})
// Not upcoming: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled",
NextDueDate: timePtr(in60Days),
IsCancelled: true,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsUpcoming(&task, now, daysThreshold) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeUpcoming returned %d tasks, IsUpcoming predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 2 upcoming tasks
if len(scopeResults) != 2 {
t.Errorf("Expected 2 upcoming tasks, got %d", len(scopeResults))
}
}
// TestScopeInProgressMatchesPredicate verifies ScopeInProgress produces same results as IsInProgress
func TestScopeInProgressMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
// For InProgress, we need to use the exact status name "In Progress" because
// the scope joins on task_taskstatus.name = 'In Progress'
// First, try to find existing "In Progress" status, or create one
var inProgressStatus models.TaskStatus
if err := testDB.Where("name = ?", "In Progress").First(&inProgressStatus).Error; err != nil {
// Create it if it doesn't exist
inProgressStatus = models.TaskStatus{Name: "In Progress"}
testDB.Create(&inProgressStatus)
}
var pendingStatus models.TaskStatus
if err := testDB.Where("name = ?", "Pending").First(&pendingStatus).Error; err != nil {
pendingStatus = models.TaskStatus{Name: "Pending"}
testDB.Create(&pendingStatus)
}
defer cleanupTestData()
// In progress task
createTestTask(t, residenceID, &models.Task{
Title: "in_progress",
StatusID: &inProgressStatus.ID,
})
// Not in progress: different status
createTestTask(t, residenceID, &models.Task{
Title: "pending",
StatusID: &pendingStatus.ID,
})
// Not in progress: no status
createTestTask(t, residenceID, &models.Task{
Title: "no_status",
StatusID: nil,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with status preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Status").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsInProgress(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeInProgress returned %d tasks, IsInProgress predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 1 in progress task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 in progress task, got %d", len(scopeResults))
}
}
// TestScopeForResidences verifies filtering by multiple residence IDs
func TestScopeForResidences(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID1 := createTestResidence(t)
residenceID2 := createTestResidence(t)
residenceID3 := createTestResidence(t)
defer cleanupTestData()
// Create tasks in different residences
createTestTask(t, residenceID1, &models.Task{Title: "task_r1"})
createTestTask(t, residenceID2, &models.Task{Title: "task_r2"})
createTestTask(t, residenceID3, &models.Task{Title: "task_r3"})
// Query for residences 1 and 2 only
var results []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidences([]uint{residenceID1, residenceID2})).
Find(&results).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 tasks from residences 1 and 2, got %d", len(results))
}
// Verify empty slice returns no results
var emptyResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidences([]uint{})).
Find(&emptyResults)
if len(emptyResults) != 0 {
t.Errorf("Expected 0 tasks for empty residence list, got %d", len(emptyResults))
}
}
// Helper to get task titles for debugging
func getTaskTitles(tasks []models.Task) []string {
titles := make([]string, len(tasks))
for i, task := range tasks {
titles[i] = task.Title
}
return titles
}