package task import ( "testing" "time" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/task/categorization" "github.com/treytartt/honeydue-api/internal/testutil" ) var now = time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC) func timePtr(t time.Time) *time.Time { return &t } func completedTask() *models.Task { return &models.Task{ BaseModel: models.BaseModel{ID: 1}, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // NextDueDate is nil → completed } } func overdueTask() *models.Task { past := now.AddDate(0, 0, -5) return &models.Task{ BaseModel: models.BaseModel{ID: 2}, DueDate: &past, } } func dueSoonTask() *models.Task { soon := now.AddDate(0, 0, 10) return &models.Task{ BaseModel: models.BaseModel{ID: 3}, DueDate: &soon, } } func activeTask() *models.Task { return &models.Task{ BaseModel: models.BaseModel{ID: 4}, } } // --- Predicate re-exports --- func TestReExport_IsCompleted(t *testing.T) { if !IsCompleted(completedTask()) { t.Error("expected completed") } if IsCompleted(activeTask()) { t.Error("expected not completed") } } func TestReExport_IsOverdue(t *testing.T) { if !IsOverdue(overdueTask(), now) { t.Error("expected overdue") } if IsOverdue(dueSoonTask(), now) { t.Error("expected not overdue") } } func TestReExport_IsDueSoon(t *testing.T) { if !IsDueSoon(dueSoonTask(), now, 30) { t.Error("expected due soon") } } func TestReExport_IsActive(t *testing.T) { if !IsActive(activeTask()) { t.Error("expected active") } cancelled := &models.Task{IsCancelled: true} if IsActive(cancelled) { t.Error("cancelled should not be active") } } func TestReExport_IsArchived(t *testing.T) { archived := &models.Task{IsArchived: true} if !IsArchived(archived) { t.Error("expected archived") } } func TestReExport_IsCancelled(t *testing.T) { cancelled := &models.Task{IsCancelled: true} if !IsCancelled(cancelled) { t.Error("expected cancelled") } } func TestReExport_IsInProgress(t *testing.T) { ip := &models.Task{InProgress: true} if !IsInProgress(ip) { t.Error("expected in progress") } } func TestReExport_IsRecurring(t *testing.T) { days := 30 freqID := uint(1) recurring := &models.Task{ FrequencyID: &freqID, Frequency: &models.TaskFrequency{Days: &days}, } if !IsRecurring(recurring) { t.Error("expected recurring") } if IsRecurring(activeTask()) { t.Error("expected not recurring") } } func TestReExport_IsOneTime(t *testing.T) { if !IsOneTime(activeTask()) { t.Error("expected one-time") } } func TestReExport_HasCompletions(t *testing.T) { if !HasCompletions(completedTask()) { t.Error("expected has completions") } if HasCompletions(activeTask()) { t.Error("expected no completions") } } func TestReExport_GetCompletionCount(t *testing.T) { ct := completedTask() if GetCompletionCount(ct) != 1 { t.Errorf("count = %d, want 1", GetCompletionCount(ct)) } } func TestReExport_EffectiveDate(t *testing.T) { task := overdueTask() ed := EffectiveDate(task) if ed == nil { t.Error("expected non-nil effective date") } // If NextDueDate is set, prefer it next := now.AddDate(0, 1, 0) task.NextDueDate = &next ed = EffectiveDate(task) if !ed.Equal(next) { t.Errorf("expected NextDueDate, got %v", ed) } } func TestReExport_IsUpcoming(t *testing.T) { far := now.AddDate(0, 6, 0) task := &models.Task{DueDate: &far} if !IsUpcoming(task, now, 30) { t.Error("expected upcoming") } } func TestReExport_CategorizeTask(t *testing.T) { col := CategorizeTask(overdueTask(), 30) if col != categorization.ColumnOverdue { t.Errorf("column = %v, want overdue", col) } } func TestReExport_DetermineKanbanColumn(t *testing.T) { col := DetermineKanbanColumn(overdueTask(), 30) if col == "" { t.Error("expected non-empty column string") } } func TestReExport_CategorizeTasksIntoColumns(t *testing.T) { tasks := []models.Task{*overdueTask(), *dueSoonTask(), *activeTask()} result := CategorizeTasksIntoColumns(tasks, 30) if result == nil { t.Error("expected non-nil result") } } func TestReExport_NewChain(t *testing.T) { chain := NewChain() if chain == nil { t.Error("expected non-nil chain") } } func TestReExport_Constants(t *testing.T) { if ColumnOverdue.String() != "overdue_tasks" { t.Errorf("ColumnOverdue = %q", ColumnOverdue.String()) } if ColumnDueSoon.String() != "due_soon_tasks" { t.Errorf("ColumnDueSoon = %q", ColumnDueSoon.String()) } if ColumnUpcoming.String() != "upcoming_tasks" { t.Errorf("ColumnUpcoming = %q", ColumnUpcoming.String()) } if ColumnInProgress.String() != "in_progress_tasks" { t.Errorf("ColumnInProgress = %q", ColumnInProgress.String()) } if ColumnCompleted.String() != "completed_tasks" { t.Errorf("ColumnCompleted = %q", ColumnCompleted.String()) } if ColumnCancelled.String() != "cancelled_tasks" { t.Errorf("ColumnCancelled = %q", ColumnCancelled.String()) } } // --- Scope re-exports (use SQLite in-memory DB) --- func setupDB(t *testing.T) *gorm.DB { return testutil.SetupTestDB(t) } func seedTask(t *testing.T, db *gorm.DB, task *models.Task) { // Ensure we have a user and residence user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") res := testutil.CreateTestResidence(t, db, user.ID, "Test House") task.CreatedByID = user.ID task.ResidenceID = res.ID task.Version = 1 err := db.Create(task).Error if err != nil { t.Fatalf("failed to create task: %v", err) } } func TestReExport_ScopeActive_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "active"}) var tasks []models.Task db.Scopes(ScopeActive).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeCancelled_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "cancelled", IsCancelled: true}) var tasks []models.Task db.Scopes(ScopeCancelled).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeArchived_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "archived", IsArchived: true}) var tasks []models.Task db.Scopes(ScopeArchived).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeInProgress_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "inprog", InProgress: true}) var tasks []models.Task db.Scopes(ScopeInProgress).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeNotInProgress_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "not inprog"}) var tasks []models.Task db.Scopes(ScopeNotInProgress).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeCompleted_DB(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u2", "u2@test.com", "password123") res := testutil.CreateTestResidence(t, db, user.ID, "House2") task := &models.Task{Title: "completed", CreatedByID: user.ID, ResidenceID: res.ID, Version: 1} db.Create(task) // Add a completion and ensure NextDueDate is nil db.Create(&models.TaskCompletion{TaskID: task.ID, CompletedByID: user.ID, CompletedAt: now}) var tasks []models.Task db.Scopes(ScopeCompleted).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeNotCompleted_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "not completed"}) var tasks []models.Task db.Scopes(ScopeNotCompleted).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeOverdue_DB(t *testing.T) { db := setupDB(t) past := now.AddDate(0, 0, -5) seedTask(t, db, &models.Task{Title: "overdue", DueDate: &past}) var tasks []models.Task db.Scopes(ScopeOverdue(now)).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeDueSoon_DB(t *testing.T) { db := setupDB(t) soon := now.AddDate(0, 0, 5) seedTask(t, db, &models.Task{Title: "due soon", DueDate: &soon}) var tasks []models.Task db.Scopes(ScopeDueSoon(now, 30)).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeUpcoming_DB(t *testing.T) { db := setupDB(t) far := now.AddDate(0, 6, 0) seedTask(t, db, &models.Task{Title: "upcoming", DueDate: &far}) var tasks []models.Task db.Scopes(ScopeUpcoming(now, 30)).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeForResidence_DB(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u3", "u3@test.com", "password123") res := testutil.CreateTestResidence(t, db, user.ID, "House3") db.Create(&models.Task{Title: "t1", CreatedByID: user.ID, ResidenceID: res.ID, Version: 1}) var tasks []models.Task db.Scopes(ScopeForResidence(res.ID)).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeForResidences_DB(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u4", "u4@test.com", "password123") res1 := testutil.CreateTestResidence(t, db, user.ID, "H1") res2 := testutil.CreateTestResidence(t, db, user.ID, "H2") db.Create(&models.Task{Title: "t1", CreatedByID: user.ID, ResidenceID: res1.ID, Version: 1}) db.Create(&models.Task{Title: "t2", CreatedByID: user.ID, ResidenceID: res2.ID, Version: 1}) var tasks []models.Task db.Scopes(ScopeForResidences([]uint{res1.ID, res2.ID})).Find(&tasks) if len(tasks) != 2 { t.Errorf("len = %d, want 2", len(tasks)) } } func TestReExport_ScopeDueInRange_DB(t *testing.T) { db := setupDB(t) due := now.AddDate(0, 0, 3) seedTask(t, db, &models.Task{Title: "in range", DueDate: &due}) var tasks []models.Task db.Scopes(ScopeDueInRange(now, now.AddDate(0, 0, 7))).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeHasDueDate_DB(t *testing.T) { db := setupDB(t) due := now.AddDate(0, 0, 1) seedTask(t, db, &models.Task{Title: "with due", DueDate: &due}) var tasks []models.Task db.Scopes(ScopeHasDueDate).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeNoDueDate_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "no due"}) var tasks []models.Task db.Scopes(ScopeNoDueDate).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeHasCompletions_DB(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u5", "u5@test.com", "password123") res := testutil.CreateTestResidence(t, db, user.ID, "H5") task := &models.Task{Title: "has comp", CreatedByID: user.ID, ResidenceID: res.ID, Version: 1} db.Create(task) db.Create(&models.TaskCompletion{TaskID: task.ID, CompletedByID: user.ID, CompletedAt: now}) var tasks []models.Task db.Scopes(ScopeHasCompletions).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeNoCompletions_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "no comp"}) var tasks []models.Task db.Scopes(ScopeNoCompletions).Find(&tasks) if len(tasks) != 1 { t.Errorf("len = %d, want 1", len(tasks)) } } func TestReExport_ScopeOrderByDueDate_DB(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u6", "u6@test.com", "password123") res := testutil.CreateTestResidence(t, db, user.ID, "H6") d1 := now.AddDate(0, 0, 5) d2 := now.AddDate(0, 0, 1) db.Create(&models.Task{Title: "later", CreatedByID: user.ID, ResidenceID: res.ID, DueDate: &d1, Version: 1}) db.Create(&models.Task{Title: "sooner", CreatedByID: user.ID, ResidenceID: res.ID, DueDate: &d2, Version: 1}) var tasks []models.Task db.Scopes(ScopeOrderByDueDate).Find(&tasks) if len(tasks) != 2 { t.Fatalf("len = %d", len(tasks)) } } func TestReExport_ScopeOrderByPriority_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "prio test"}) var tasks []models.Task db.Scopes(ScopeOrderByPriority).Find(&tasks) if len(tasks) < 1 { t.Error("expected at least 1 task") } } func TestReExport_ScopeOrderByCreatedAt_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "created test"}) var tasks []models.Task db.Scopes(ScopeOrderByCreatedAt).Find(&tasks) if len(tasks) < 1 { t.Error("expected at least 1 task") } } func TestReExport_ScopeKanbanOrder_DB(t *testing.T) { db := setupDB(t) seedTask(t, db, &models.Task{Title: "kanban test"}) var tasks []models.Task db.Scopes(ScopeKanbanOrder).Find(&tasks) if len(tasks) < 1 { t.Error("expected at least 1 task") } }