Files
honeyDueAPI/internal/task/categorization/chain_breakit_test.go
treyt e26116e2cf Add webhook logging, pagination, middleware, migrations, and prod hardening
- Webhook event logging repo and subscription webhook idempotency
- Pagination helper (echohelpers) with cursor/offset support
- Request ID and structured logging middleware
- Push client improvements (FCM HTTP v1, better error handling)
- Task model version column, business constraint migrations, targeted indexes
- Expanded categorization chain tests
- Email service and config hardening
- CI workflow updates, .gitignore additions, .env.example updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:32:09 -06:00

242 lines
8.0 KiB
Go

package categorization_test
import (
"math/rand"
"testing"
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
)
// validColumns is the complete set of KanbanColumn values the chain may return.
var validColumns = map[categorization.KanbanColumn]bool{
categorization.ColumnOverdue: true,
categorization.ColumnDueSoon: true,
categorization.ColumnUpcoming: true,
categorization.ColumnInProgress: true,
categorization.ColumnCompleted: true,
categorization.ColumnCancelled: true,
}
// FuzzCategorizeTask feeds random task states into CategorizeTask and asserts
// that the result is always a non-empty, valid KanbanColumn constant.
func FuzzCategorizeTask(f *testing.F) {
f.Add(false, false, false, false, false, 0, false, 0)
f.Add(true, false, false, false, false, 0, false, 0)
f.Add(false, true, false, false, false, 0, false, 0)
f.Add(false, false, true, false, false, 0, false, 0)
f.Add(false, false, false, true, false, 0, false, 0)
f.Add(false, false, false, false, true, -5, false, 0)
f.Add(false, false, false, false, false, 0, true, -5)
f.Add(false, false, false, false, false, 0, true, 5)
f.Add(false, false, false, false, false, 0, true, 60)
f.Add(true, true, true, true, true, -10, true, -10)
f.Add(false, false, false, false, true, 100, true, 100)
f.Fuzz(func(t *testing.T,
isCancelled, isArchived, inProgress, hasCompletions bool,
hasDueDate bool, dueDateOffsetDays int,
hasNextDueDate bool, nextDueDateOffsetDays int,
) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
IsCancelled: isCancelled,
IsArchived: isArchived,
InProgress: inProgress,
}
if hasDueDate {
d := now.AddDate(0, 0, dueDateOffsetDays)
task.DueDate = &d
}
if hasNextDueDate {
d := now.AddDate(0, 0, nextDueDateOffsetDays)
task.NextDueDate = &d
}
if hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTask(task, 30)
if result == "" {
t.Fatalf("CategorizeTask returned empty string for task %+v", task)
}
if !validColumns[result] {
t.Fatalf("CategorizeTask returned invalid column %q for task %+v", result, task)
}
})
}
// === Property Tests (1000 random tasks) ===
// TestCategorizeTask_PropertyEveryTaskMapsToExactlyOneColumn uses random tasks
// to validate the property that every task maps to exactly one column.
func TestCategorizeTask_PropertyEveryTaskMapsToExactlyOneColumn(t *testing.T) {
rng := rand.New(rand.NewSource(42)) // Deterministic seed for reproducibility
now := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
for i := 0; i < 1000; i++ {
task := randomTask(rng, now)
column := categorization.CategorizeTask(task, 30)
if !validColumns[column] {
t.Fatalf("Task %d mapped to invalid column %q: %+v", i, column, task)
}
}
}
// TestCategorizeTask_CancelledAlwaysWins validates that cancelled takes priority
// over all other states regardless of other flags using randomized tasks.
func TestCategorizeTask_CancelledAlwaysWins(t *testing.T) {
rng := rand.New(rand.NewSource(42))
now := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
for i := 0; i < 500; i++ {
task := randomTask(rng, now)
task.IsCancelled = true
column := categorization.CategorizeTask(task, 30)
if column != categorization.ColumnCancelled {
t.Fatalf("Cancelled task %d mapped to %q instead of cancelled_tasks: %+v", i, column, task)
}
}
}
// === Timezone / DST Boundary Tests ===
// TestCategorizeTask_UTCMidnightBoundary tests task categorization at exactly
// UTC midnight, which is the boundary between days.
func TestCategorizeTask_UTCMidnightBoundary(t *testing.T) {
midnight := time.Date(2025, 3, 9, 0, 0, 0, 0, time.UTC)
dueDate := midnight
task := &models.Task{
DueDate: &dueDate,
}
// At midnight of the due date, task is NOT overdue (due today)
column := categorization.CategorizeTaskWithTime(task, 30, midnight)
if column == categorization.ColumnOverdue {
t.Errorf("Task due today should not be overdue at midnight, got %q", column)
}
// One day later, task IS overdue
nextDay := midnight.AddDate(0, 0, 1)
column = categorization.CategorizeTaskWithTime(task, 30, nextDay)
if column != categorization.ColumnOverdue {
t.Errorf("Task due yesterday should be overdue, got %q", column)
}
}
// TestCategorizeTask_DSTSpringForward tests categorization across DST spring-forward.
// In US Eastern time, 2:00 AM jumps to 3:00 AM on the second Sunday of March.
func TestCategorizeTask_DSTSpringForward(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skip("America/New_York timezone not available")
}
// March 9, 2025 is DST spring-forward in Eastern Time
dueDate := time.Date(2025, 3, 9, 0, 0, 0, 0, time.UTC) // Stored as UTC
task := &models.Task{DueDate: &dueDate}
// Check at start of March 9 in Eastern time
nowET := time.Date(2025, 3, 9, 0, 0, 0, 0, loc)
column := categorization.CategorizeTaskWithTime(task, 30, nowET)
if column == categorization.ColumnOverdue {
t.Errorf("Task due March 9 should not be overdue on March 9 (DST spring-forward), got %q", column)
}
// Check at March 10 - should be overdue now
nextDayET := time.Date(2025, 3, 10, 0, 0, 0, 0, loc)
column = categorization.CategorizeTaskWithTime(task, 30, nextDayET)
if column != categorization.ColumnOverdue {
t.Errorf("Task due March 9 should be overdue on March 10, got %q", column)
}
}
// TestCategorizeTask_DSTFallBack tests categorization across DST fall-back.
// In US Eastern time, 2:00 AM jumps back to 1:00 AM on the first Sunday of November.
func TestCategorizeTask_DSTFallBack(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skip("America/New_York timezone not available")
}
// November 2, 2025 is DST fall-back in Eastern Time
dueDate := time.Date(2025, 11, 2, 0, 0, 0, 0, time.UTC)
task := &models.Task{DueDate: &dueDate}
// On the due date itself - not overdue
nowET := time.Date(2025, 11, 2, 0, 0, 0, 0, loc)
column := categorization.CategorizeTaskWithTime(task, 30, nowET)
if column == categorization.ColumnOverdue {
t.Errorf("Task due Nov 2 should not be overdue on Nov 2 (DST fall-back), got %q", column)
}
// Next day - should be overdue
nextDayET := time.Date(2025, 11, 3, 0, 0, 0, 0, loc)
column = categorization.CategorizeTaskWithTime(task, 30, nextDayET)
if column != categorization.ColumnOverdue {
t.Errorf("Task due Nov 2 should be overdue on Nov 3, got %q", column)
}
}
// TestIsOverdue_UTCMidnightEdge validates the overdue predicate at exact midnight.
func TestIsOverdue_UTCMidnightEdge(t *testing.T) {
dueDate := time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)
task := &models.Task{DueDate: &dueDate}
// On due date: NOT overdue
atDueDate := time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)
column := categorization.CategorizeTaskWithTime(task, 30, atDueDate)
if column == categorization.ColumnOverdue {
t.Error("Task should not be overdue on its due date")
}
// One second after midnight next day: overdue
afterDueDate := time.Date(2026, 1, 1, 0, 0, 1, 0, time.UTC)
column = categorization.CategorizeTaskWithTime(task, 30, afterDueDate)
if column != categorization.ColumnOverdue {
t.Errorf("Task should be overdue after its due date, got %q", column)
}
}
// === Helper ===
func randomTask(rng *rand.Rand, baseTime time.Time) *models.Task {
task := &models.Task{
IsCancelled: rng.Intn(10) == 0, // 10% chance
IsArchived: rng.Intn(10) == 0, // 10% chance
InProgress: rng.Intn(5) == 0, // 20% chance
}
if rng.Intn(4) > 0 { // 75% have due date
d := baseTime.AddDate(0, 0, rng.Intn(120)-60)
task.DueDate = &d
}
if rng.Intn(3) == 0 { // 33% recurring
d := baseTime.AddDate(0, 0, rng.Intn(120)-60)
task.NextDueDate = &d
}
if rng.Intn(3) == 0 { // 33% have completions
count := rng.Intn(3) + 1
for i := 0; i < count; i++ {
task.Completions = append(task.Completions, models.TaskCompletion{
BaseModel: models.BaseModel{ID: uint(i + 1)},
})
}
}
return task
}