package scopes_test import ( "testing" "time" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/task/scopes" "github.com/treytartt/honeydue-api/internal/testutil" ) // --- helpers --- func setupDB(t *testing.T) *gorm.DB { return testutil.SetupTestDB(t) } func timePtr(t time.Time) *time.Time { return &t } func createResidence(t *testing.T, db *gorm.DB) uint { user := testutil.CreateTestUser(t, db, "scope_user", "scope@example.com", "pass") r := testutil.CreateTestResidence(t, db, user.ID, "Scope Home") return r.ID } func createTask(t *testing.T, db *gorm.DB, task *models.Task) *models.Task { if err := db.Create(task).Error; err != nil { t.Fatalf("create task: %v", err) } return task } func createCompletion(t *testing.T, db *gorm.DB, taskID, userID uint) { c := &models.TaskCompletion{ TaskID: taskID, CompletedByID: userID, CompletedAt: time.Now().UTC(), } if err := db.Create(c).Error; err != nil { t.Fatalf("create completion: %v", err) } } func queryCount(t *testing.T, db *gorm.DB, scopeFns ...func(*gorm.DB) *gorm.DB) int { var tasks []models.Task q := db.Model(&models.Task{}) for _, fn := range scopeFns { q = q.Scopes(fn) } if err := q.Find(&tasks).Error; err != nil { t.Fatalf("query: %v", err) } return len(tasks) } // --- ScopeActive --- func TestScopeActive(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u1", "u1@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R1") createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "active"}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "cancelled", IsCancelled: true}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "archived", IsArchived: true}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeActive) if got != 1 { t.Errorf("active = %d, want 1", got) } } // --- ScopeCancelled --- func TestScopeCancelled(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u2", "u2@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R2") createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "ok"}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "cancelled", IsCancelled: true}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeCancelled) if got != 1 { t.Errorf("cancelled = %d, want 1", got) } } // --- ScopeArchived --- func TestScopeArchived(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u3", "u3@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R3") createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "ok"}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "archived", IsArchived: true}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeArchived) if got != 1 { t.Errorf("archived = %d, want 1", got) } } // --- ScopeInProgress / ScopeNotInProgress --- func TestScopeInProgress(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u4", "u4@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R4") createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "ip", InProgress: true}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "not_ip"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeInProgress) if got != 1 { t.Errorf("in_progress = %d, want 1", got) } } func TestScopeNotInProgress(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u5", "u5@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R5") createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "ip", InProgress: true}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "not_ip"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeNotInProgress) if got != 1 { t.Errorf("not_in_progress = %d, want 1", got) } } // --- ScopeCompleted / ScopeNotCompleted --- func TestScopeCompleted(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u6", "u6@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R6") // Completed: NextDueDate nil + has completion completed := createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "done"}) createCompletion(t, db, completed.ID, user.ID) // Not completed: has NextDueDate (recurring) nextWeek := time.Now().AddDate(0, 0, 7) recurring := createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "recurring", NextDueDate: &nextWeek}) createCompletion(t, db, recurring.ID, user.ID) // Not completed: no completions createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "pending"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeCompleted) if got != 1 { t.Errorf("completed = %d, want 1", got) } } func TestScopeNotCompleted(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u7", "u7@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R7") completed := createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "done"}) createCompletion(t, db, completed.ID, user.ID) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "pending"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeNotCompleted) if got != 1 { t.Errorf("not_completed = %d, want 1", got) } } // --- ScopeOverdue --- func TestScopeOverdue(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u8", "u8@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R8") now := time.Now().UTC() yesterday := now.AddDate(0, 0, -1) tomorrow := now.AddDate(0, 0, 1) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "overdue", NextDueDate: timePtr(yesterday)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "future", NextDueDate: timePtr(tomorrow)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "cancelled_overdue", NextDueDate: timePtr(yesterday), IsCancelled: true}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeOverdue(now)) if got != 1 { t.Errorf("overdue = %d, want 1", got) } } // --- ScopeDueSoon --- func TestScopeDueSoon(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u9", "u9@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R9") now := time.Now().UTC() in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) yesterday := now.AddDate(0, 0, -1) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "due_soon", NextDueDate: timePtr(in5Days)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "far", NextDueDate: timePtr(in60Days)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "overdue", NextDueDate: timePtr(yesterday)}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeDueSoon(now, 30)) if got != 1 { t.Errorf("due_soon = %d, want 1", got) } } // --- ScopeUpcoming --- func TestScopeUpcoming(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u10", "u10@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R10") now := time.Now().UTC() in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "due_soon", NextDueDate: timePtr(in5Days)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "upcoming", NextDueDate: timePtr(in60Days)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "no_date"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeUpcoming(now, 30)) if got != 2 { t.Errorf("upcoming = %d, want 2", got) } } // --- ScopeForResidence / ScopeForResidences --- func TestScopeForResidence(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u11", "u11@x.com", "p") r1 := testutil.CreateTestResidence(t, db, user.ID, "R11a") r2 := testutil.CreateTestResidence(t, db, user.ID, "R11b") createTask(t, db, &models.Task{ResidenceID: r1.ID, CreatedByID: user.ID, Title: "t1"}) createTask(t, db, &models.Task{ResidenceID: r2.ID, CreatedByID: user.ID, Title: "t2"}) got := queryCount(t, db, scopes.ScopeForResidence(r1.ID)) if got != 1 { t.Errorf("for_residence = %d, want 1", got) } } func TestScopeForResidences(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u12", "u12@x.com", "p") r1 := testutil.CreateTestResidence(t, db, user.ID, "R12a") r2 := testutil.CreateTestResidence(t, db, user.ID, "R12b") r3 := testutil.CreateTestResidence(t, db, user.ID, "R12c") createTask(t, db, &models.Task{ResidenceID: r1.ID, CreatedByID: user.ID, Title: "t1"}) createTask(t, db, &models.Task{ResidenceID: r2.ID, CreatedByID: user.ID, Title: "t2"}) createTask(t, db, &models.Task{ResidenceID: r3.ID, CreatedByID: user.ID, Title: "t3"}) got := queryCount(t, db, scopes.ScopeForResidences([]uint{r1.ID, r2.ID})) if got != 2 { t.Errorf("for_residences = %d, want 2", got) } } // --- ScopeDueInRange --- func TestScopeDueInRange(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u13", "u13@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R13") now := time.Now().UTC() in3Days := now.AddDate(0, 0, 3) in10Days := now.AddDate(0, 0, 10) in20Days := now.AddDate(0, 0, 20) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "in_range", NextDueDate: timePtr(in10Days)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "before_range", NextDueDate: timePtr(in3Days)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "after_range", NextDueDate: timePtr(in20Days)}) start := now.AddDate(0, 0, 5) end := now.AddDate(0, 0, 15) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeDueInRange(start, end)) if got != 1 { t.Errorf("due_in_range = %d, want 1", got) } } // --- ScopeHasDueDate / ScopeNoDueDate --- func TestScopeHasDueDate(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u14", "u14@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R14") tomorrow := time.Now().AddDate(0, 0, 1) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "with_date", NextDueDate: timePtr(tomorrow)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "with_due", DueDate: timePtr(tomorrow)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "no_date"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeHasDueDate) if got != 2 { t.Errorf("has_due_date = %d, want 2", got) } } func TestScopeNoDueDate(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u15", "u15@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R15") tomorrow := time.Now().AddDate(0, 0, 1) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "with_date", NextDueDate: timePtr(tomorrow)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "no_date"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeNoDueDate) if got != 1 { t.Errorf("no_due_date = %d, want 1", got) } } // --- ScopeHasCompletions / ScopeNoCompletions --- func TestScopeHasCompletions(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u16", "u16@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R16") withC := createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "with_completion"}) createCompletion(t, db, withC.ID, user.ID) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "without_completion"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeHasCompletions) if got != 1 { t.Errorf("has_completions = %d, want 1", got) } } func TestScopeNoCompletions(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u17", "u17@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R17") withC := createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "with_completion"}) createCompletion(t, db, withC.ID, user.ID) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "without_completion"}) got := queryCount(t, db, scopes.ScopeForResidence(r.ID), scopes.ScopeNoCompletions) if got != 1 { t.Errorf("no_completions = %d, want 1", got) } } // --- Ordering scopes --- func TestScopeOrderByDueDate(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u18", "u18@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R18") in10 := time.Now().AddDate(0, 0, 10) in5 := time.Now().AddDate(0, 0, 5) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "later", NextDueDate: timePtr(in10)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "sooner", NextDueDate: timePtr(in5)}) var tasks []models.Task db.Model(&models.Task{}).Scopes(scopes.ScopeForResidence(r.ID), scopes.ScopeOrderByDueDate).Find(&tasks) if len(tasks) != 2 { t.Fatalf("len = %d, want 2", len(tasks)) } // First should have the earlier date (sooner) if tasks[0].Title != "sooner" { t.Errorf("first task = %q, want sooner", tasks[0].Title) } } func TestScopeKanbanOrder(t *testing.T) { db := setupDB(t) user := testutil.CreateTestUser(t, db, "u19", "u19@x.com", "p") r := testutil.CreateTestResidence(t, db, user.ID, "R19") in10 := time.Now().AddDate(0, 0, 10) in5 := time.Now().AddDate(0, 0, 5) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "later", NextDueDate: timePtr(in10)}) createTask(t, db, &models.Task{ResidenceID: r.ID, CreatedByID: user.ID, Title: "sooner", NextDueDate: timePtr(in5)}) var tasks []models.Task db.Model(&models.Task{}).Scopes(scopes.ScopeForResidence(r.ID), scopes.ScopeKanbanOrder).Find(&tasks) if len(tasks) != 2 { t.Fatalf("len = %d, want 2", len(tasks)) } }