Files
honeyDueAPI/internal/task/consistency_test.go
Trey t 4976eafc6c Rebrand from Casera/MyCrib to honeyDue
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>
2026-03-07 06:33:38 -06:00

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
}