Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests

- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests)
- Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests)
- Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests)
- Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests)
- Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests)
- Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-01 20:30:09 -05:00
parent 00fd674b56
commit bec880886b
83 changed files with 19569 additions and 730 deletions

467
internal/task/task_test.go Normal file
View File

@@ -0,0 +1,467 @@
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")
}
}