// Package task provides consistency tests that verify all three layers // (predicates, scopes, and categorization) return identical results. // // These tests are critical for ensuring the DRY architecture is maintained. // If any of these tests fail, it means the three layers have diverged and // will produce inconsistent results in different parts of the application. package task_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/categorization" "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 // testUserID is a user ID that exists in the database for foreign key constraints var testUserID uint = 1 // TestMain sets up the database connection for all tests in this package func TestMain(m *testing.M) { 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 { println("Skipping consistency integration tests: database not available") println("Set TEST_DATABASE_URL to run these tests") os.Exit(0) } sqlDB, err := testDB.DB() if err != nil || sqlDB.Ping() != nil { println("Failed to connect to database") os.Exit(0) } println("Database connected, running consistency tests...") code := m.Run() cleanupTestData() os.Exit(code) } func cleanupTestData() { if testDB == nil { return } testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'consistency_test_%')") testDB.Exec("DELETE FROM task_task WHERE title LIKE 'consistency_test_%'") testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'consistency_test_%'") } func timePtr(t time.Time) *time.Time { return &t } func createResidence(t *testing.T) uint { residence := &models.Residence{ Name: "consistency_test_" + time.Now().Format("20060102150405.000"), OwnerID: testUserID, IsActive: true, } if err := testDB.Create(residence).Error; err != nil { t.Fatalf("Failed to create residence: %v", err) } return residence.ID } func createTask(t *testing.T, residenceID uint, task *models.Task) *models.Task { task.ResidenceID = residenceID task.Title = "consistency_test_" + task.Title task.CreatedByID = testUserID if err := testDB.Create(task).Error; err != nil { t.Fatalf("Failed to create task: %v", err) } return task } func createCompletion(t *testing.T, taskID uint) { completion := &models.TaskCompletion{ TaskID: taskID, CompletedByID: testUserID, CompletedAt: time.Now().UTC(), } if err := testDB.Create(completion).Error; err != nil { t.Fatalf("Failed to create completion: %v", err) } } // TaskTestCase defines a test scenario with expected categorization type TaskTestCase struct { Name string Task *models.Task HasCompletion bool ExpectedColumn categorization.KanbanColumn // Expected predicate results ExpectCompleted bool ExpectActive bool ExpectOverdue bool ExpectDueSoon bool ExpectUpcoming bool ExpectInProgress bool } // TestAllThreeLayersMatch is the master consistency test. // It creates tasks in the database, then verifies that: // 1. Predicates return the expected boolean values // 2. Categorization returns the expected kanban column // 3. Scopes return the same tasks that predicates would filter func TestAllThreeLayersMatch(t *testing.T) { if testDB == nil { t.Skip("Database not available") } residenceID := createResidence(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 // Define all test cases with expected results for each layer testCases := []TaskTestCase{ { Name: "overdue_active", Task: &models.Task{ Title: "overdue_active", NextDueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnOverdue, ExpectCompleted: false, ExpectActive: true, ExpectOverdue: true, ExpectDueSoon: false, ExpectUpcoming: false, }, { Name: "due_soon_active", Task: &models.Task{ Title: "due_soon_active", NextDueDate: timePtr(in5Days), IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnDueSoon, ExpectCompleted: false, ExpectActive: true, ExpectOverdue: false, ExpectDueSoon: true, ExpectUpcoming: false, }, { Name: "upcoming_far_future", Task: &models.Task{ Title: "upcoming_far_future", NextDueDate: timePtr(in60Days), IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnUpcoming, ExpectCompleted: false, ExpectActive: true, ExpectOverdue: false, ExpectDueSoon: false, ExpectUpcoming: true, }, { Name: "upcoming_no_due_date", Task: &models.Task{ Title: "upcoming_no_due_date", NextDueDate: nil, DueDate: nil, IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnUpcoming, ExpectCompleted: false, ExpectActive: true, ExpectOverdue: false, ExpectDueSoon: false, ExpectUpcoming: true, }, { Name: "completed_one_time", Task: &models.Task{ Title: "completed_one_time", NextDueDate: nil, // No next due date DueDate: timePtr(yesterday), IsCancelled: false, IsArchived: false, }, HasCompletion: true, // Will create a completion ExpectedColumn: categorization.ColumnCompleted, ExpectCompleted: true, ExpectActive: true, ExpectOverdue: false, // Completed tasks are not overdue ExpectDueSoon: false, ExpectUpcoming: false, }, { Name: "cancelled_task", Task: &models.Task{ Title: "cancelled_task", NextDueDate: timePtr(yesterday), // Would be overdue if not cancelled IsCancelled: true, IsArchived: false, }, ExpectedColumn: categorization.ColumnCancelled, ExpectCompleted: false, ExpectActive: false, // Cancelled is not active ExpectOverdue: false, // Cancelled tasks are not overdue ExpectDueSoon: false, ExpectUpcoming: false, }, { Name: "archived_task", Task: &models.Task{ Title: "archived_task", NextDueDate: timePtr(yesterday), IsCancelled: false, IsArchived: true, }, // Archived tasks don't appear in kanban (filtered out before categorization) // but we test predicates still work ExpectedColumn: categorization.ColumnOverdue, // Chain doesn't check archived ExpectCompleted: false, ExpectActive: false, // Archived is not active ExpectOverdue: false, // Archived tasks are not overdue (IsOverdue checks IsActive) ExpectDueSoon: false, ExpectUpcoming: false, }, { Name: "recurring_with_completion", Task: &models.Task{ Title: "recurring_with_completion", NextDueDate: timePtr(in5Days), // Has next due date (recurring) IsCancelled: false, IsArchived: false, }, HasCompletion: true, ExpectedColumn: categorization.ColumnDueSoon, // Not completed because NextDueDate is set ExpectCompleted: false, // Has completion but NextDueDate is set ExpectActive: true, ExpectOverdue: false, ExpectDueSoon: true, ExpectUpcoming: false, }, { Name: "overdue_uses_duedate_fallback", Task: &models.Task{ Title: "overdue_uses_duedate_fallback", NextDueDate: nil, DueDate: timePtr(yesterday), // Falls back to DueDate IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnOverdue, ExpectCompleted: false, ExpectActive: true, ExpectOverdue: true, ExpectDueSoon: false, ExpectUpcoming: false, }, { Name: "in_progress_overdue", Task: &models.Task{ Title: "in_progress_overdue", NextDueDate: timePtr(yesterday), // Would be overdue InProgress: true, IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority ExpectCompleted: false, ExpectActive: true, ExpectOverdue: true, // Predicate says overdue (doesn't check InProgress) ExpectDueSoon: false, ExpectUpcoming: false, ExpectInProgress: true, }, } // Create all tasks in database createdTasks := make(map[string]*models.Task) for _, tc := range testCases { task := createTask(t, residenceID, tc.Task) if tc.HasCompletion { createCompletion(t, task.ID) } createdTasks[tc.Name] = task } // Reload all tasks with preloads for predicate testing var allTasks []models.Task err := testDB. Preload("Completions"). Where("residence_id = ?", residenceID). Find(&allTasks).Error if err != nil { t.Fatalf("Failed to load tasks: %v", err) } // Create a map for easy lookup taskMap := make(map[string]*models.Task) for i := range allTasks { // Strip the prefix for lookup name := allTasks[i].Title[len("consistency_test_"):] taskMap[name] = &allTasks[i] } // Test each case for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { task := taskMap[tc.Name] if task == nil { t.Fatalf("Task %s not found", tc.Name) } // ========== TEST PREDICATES ========== t.Run("predicates", func(t *testing.T) { if got := predicates.IsCompleted(task); got != tc.ExpectCompleted { t.Errorf("IsCompleted() = %v, want %v", got, tc.ExpectCompleted) } if got := predicates.IsActive(task); got != tc.ExpectActive { t.Errorf("IsActive() = %v, want %v", got, tc.ExpectActive) } if got := predicates.IsOverdue(task, now); got != tc.ExpectOverdue { t.Errorf("IsOverdue() = %v, want %v", got, tc.ExpectOverdue) } if got := predicates.IsDueSoon(task, now, daysThreshold); got != tc.ExpectDueSoon { t.Errorf("IsDueSoon() = %v, want %v", got, tc.ExpectDueSoon) } if got := predicates.IsUpcoming(task, now, daysThreshold); got != tc.ExpectUpcoming { t.Errorf("IsUpcoming() = %v, want %v", got, tc.ExpectUpcoming) } if tc.ExpectInProgress { if got := predicates.IsInProgress(task); got != tc.ExpectInProgress { t.Errorf("IsInProgress() = %v, want %v", got, tc.ExpectInProgress) } } }) // ========== TEST CATEGORIZATION ========== t.Run("categorization", func(t *testing.T) { got := categorization.CategorizeTask(task, daysThreshold) if got != tc.ExpectedColumn { t.Errorf("CategorizeTask() = %v, want %v", got, tc.ExpectedColumn) } }) }) } // ========== TEST SCOPES MATCH PREDICATES ========== // This is the critical test: query with scopes and verify results match predicate filtering t.Run("scopes_match_predicates", func(t *testing.T) { // Test ScopeActive t.Run("ScopeActive", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive). Find(&scopeResults) predicateCount := 0 for _, task := range allTasks { if predicates.IsActive(&task) { predicateCount++ } } if len(scopeResults) != predicateCount { t.Errorf("ScopeActive returned %d, predicates found %d", len(scopeResults), predicateCount) } }) // Test ScopeCompleted t.Run("ScopeCompleted", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted). Find(&scopeResults) predicateCount := 0 for _, task := range allTasks { if predicates.IsCompleted(&task) { predicateCount++ } } if len(scopeResults) != predicateCount { t.Errorf("ScopeCompleted returned %d, predicates found %d", len(scopeResults), predicateCount) } }) // Test ScopeOverdue t.Run("ScopeOverdue", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). Find(&scopeResults) predicateCount := 0 for _, task := range allTasks { if predicates.IsOverdue(&task, now) { predicateCount++ } } if len(scopeResults) != predicateCount { t.Errorf("ScopeOverdue returned %d, predicates found %d", len(scopeResults), predicateCount) t.Logf("Scope results: %v", getTaskNames(scopeResults)) t.Logf("Predicate matches: %v", getPredicateMatches(allTasks, func(task *models.Task) bool { return predicates.IsOverdue(task, now) })) } }) // Test ScopeDueSoon t.Run("ScopeDueSoon", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)). Find(&scopeResults) predicateCount := 0 for _, task := range allTasks { if predicates.IsDueSoon(&task, now, daysThreshold) { predicateCount++ } } if len(scopeResults) != predicateCount { t.Errorf("ScopeDueSoon returned %d, predicates found %d", len(scopeResults), predicateCount) } }) // Test ScopeUpcoming t.Run("ScopeUpcoming", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)). Find(&scopeResults) predicateCount := 0 for _, task := range allTasks { if predicates.IsUpcoming(&task, now, daysThreshold) { predicateCount++ } } if len(scopeResults) != predicateCount { t.Errorf("ScopeUpcoming returned %d, predicates found %d", len(scopeResults), predicateCount) } }) // Test ScopeInProgress t.Run("ScopeInProgress", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress). Find(&scopeResults) predicateCount := 0 for _, task := range allTasks { if predicates.IsInProgress(&task) { predicateCount++ } } if len(scopeResults) != predicateCount { t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount) } }) }) // ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ========== // Verify that tasks categorized into each column match what scopes would return t.Run("categorization_matches_scopes", func(t *testing.T) { // Get categorization results categorized := categorization.CategorizeTasksIntoColumns(allTasks, daysThreshold) // Compare overdue column with scope // NOTE: Scopes return tasks based on date criteria only. // Categorization uses priority order (In Progress > Overdue). // So a task that is overdue by date but "In Progress" won't be in ColumnOverdue. // We need to compare scope results MINUS those with higher-priority categorization. t.Run("overdue_column", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). Find(&scopeResults) // Filter scope results to exclude tasks that would be categorized differently // (i.e., tasks that are In Progress - higher priority than Overdue) scopeOverdueNotInProgress := 0 for _, task := range scopeResults { if !predicates.IsInProgress(&task) { scopeOverdueNotInProgress++ } } // Count active overdue tasks in categorization activeOverdue := 0 for _, task := range categorized[categorization.ColumnOverdue] { if predicates.IsActive(&task) { activeOverdue++ } } if scopeOverdueNotInProgress != activeOverdue { t.Errorf("Overdue: scope returned %d (excluding in-progress), categorization has %d active", scopeOverdueNotInProgress, activeOverdue) } }) // Compare due soon column with scope t.Run("due_soon_column", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)). Find(&scopeResults) activeDueSoon := 0 for _, task := range categorized[categorization.ColumnDueSoon] { if predicates.IsActive(&task) { activeDueSoon++ } } if len(scopeResults) != activeDueSoon { t.Errorf("DueSoon: scope returned %d, categorization has %d active", len(scopeResults), activeDueSoon) } }) // Compare completed column with scope t.Run("completed_column", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted). Find(&scopeResults) if len(scopeResults) != len(categorized[categorization.ColumnCompleted]) { t.Errorf("Completed: scope returned %d, categorization has %d", len(scopeResults), len(categorized[categorization.ColumnCompleted])) } }) }) } // TestSameDayOverdueConsistency is a regression test for the DATE vs TIMESTAMP bug. // It verifies all three layers handle same-day tasks consistently. func TestSameDayOverdueConsistency(t *testing.T) { if testDB == nil { t.Skip("Database not available") } residenceID := createResidence(t) defer cleanupTestData() // Create a task due at midnight today now := time.Now().UTC() todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) task := createTask(t, residenceID, &models.Task{ Title: "same_day_midnight", NextDueDate: timePtr(todayMidnight), IsCancelled: false, IsArchived: false, }) // Reload with preloads var loadedTask models.Task testDB.Preload("Completions").First(&loadedTask, task.ID) // All three layers should agree predicateResult := predicates.IsOverdue(&loadedTask, now) var scopeResults []models.Task testDB.Model(&models.Task{}). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). Find(&scopeResults) scopeResult := len(scopeResults) == 1 categorizationResult := categorization.CategorizeTask(&loadedTask, 30) == categorization.ColumnOverdue // If current time is after midnight, all should say overdue if now.After(todayMidnight) { if !predicateResult { t.Error("Predicate says NOT overdue, but time is after midnight") } if !scopeResult { t.Error("Scope says NOT overdue, but time is after midnight") } if !categorizationResult { t.Error("Categorization says NOT overdue, but time is after midnight") } } // Most importantly: all three must agree if predicateResult != scopeResult { t.Errorf("INCONSISTENCY: predicate=%v, scope=%v", predicateResult, scopeResult) } if predicateResult != categorizationResult { t.Errorf("INCONSISTENCY: predicate=%v, categorization=%v", predicateResult, categorizationResult) } } // Helper functions func getTaskNames(tasks []models.Task) []string { names := make([]string, len(tasks)) for i, t := range tasks { names[i] = t.Title } return names } func getPredicateMatches(tasks []models.Task, predicate func(*models.Task) bool) []string { var names []string for _, t := range tasks { if predicate(&t) { names = append(names, t.Title) } } return names }