Total rebrand across all Go API source files: - Go module path: casera-api -> honeydue-api - All imports updated (130+ files) - Docker: containers, images, networks renamed - Email templates: support email, noreply, icon URL - Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - IAP product IDs updated - Landing page, admin panel, config defaults - Seeds, CI workflows, Makefile, docs - Database table names preserved (no migration needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
650 lines
19 KiB
Go
650 lines
19 KiB
Go
// 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/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/task/categorization"
|
|
"github.com/treytartt/honeydue-api/internal/task/predicates"
|
|
"github.com/treytartt/honeydue-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=honeydue_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 day-based overdue logic.
|
|
// With day-based comparisons, a task due TODAY (at any time) is NOT overdue during that day.
|
|
// It only becomes overdue the NEXT day. This test verifies all three layers agree.
|
|
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
|
|
|
|
// With day-based comparison: task due TODAY is NOT overdue during that day.
|
|
// All three layers should say NOT overdue.
|
|
if predicateResult {
|
|
t.Error("Predicate incorrectly says overdue for same-day task")
|
|
}
|
|
if scopeResult {
|
|
t.Error("Scope incorrectly says overdue for same-day task")
|
|
}
|
|
if categorizationResult {
|
|
t.Error("Categorization incorrectly says overdue for same-day task")
|
|
}
|
|
|
|
// 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
|
|
}
|