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.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 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 } // 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 day-based overdue comparison. // With day-based logic, a task due TODAY is NOT overdue during that same day. // It only becomes overdue the NEXT day. Both scope and predicate should agree. 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: with day-based comparison, task due today is NOT overdue if len(scopeResults) != len(predicateResults) { t.Errorf("Scope/predicate mismatch! Scope returned %d, predicate returned %d", len(scopeResults), len(predicateResults)) } // With day-based comparison, task due today should NOT be overdue (it's due soon) if len(scopeResults) != 0 { t.Errorf("Task due today should NOT be overdue, got %d results (expected 0)", 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) defer cleanupTestData() // In progress task createTestTask(t, residenceID, &models.Task{ Title: "in_progress", InProgress: true, }) // Not in progress: InProgress is false createTestTask(t, residenceID, &models.Task{ Title: "not_in_progress", InProgress: false, }) // 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 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.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 }