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>
This commit is contained in:
treyt
2026-02-24 21:32:09 -06:00
parent 806bd07f80
commit e26116e2cf
50 changed files with 1681 additions and 97 deletions

View File

@@ -0,0 +1,241 @@
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
}

View File

@@ -4,10 +4,14 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
)
// Ensure assert is used (referenced in fuzz/property tests below)
var _ = assert.Equal
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
@@ -545,3 +549,255 @@ func TestTimezone_MultipleTasksIntoColumns(t *testing.T) {
t.Errorf("Expected task 3 (Jan 15) in due_soon column")
}
}
// ============================================================================
// FUZZ / PROPERTY TESTS
// These tests verify invariants that must hold for ALL possible task states,
// not just specific hand-crafted examples.
//
// validColumns is defined in chain_breakit_test.go and shared across test files
// in the categorization_test package.
// ============================================================================
// FuzzCategorizeTaskExtended feeds random task states into CategorizeTask using
// separate boolean flags for date presence and day-offset integers for date
// values. This complements FuzzCategorizeTask (in chain_breakit_test.go) by
// exercising the nil-date paths more directly.
func FuzzCategorizeTaskExtended(f *testing.F) {
// Seed corpus: cover a representative spread of boolean/date combinations.
// isCancelled, isArchived, inProgress, hasCompletions,
// hasDueDate, dueDateOffsetDays, hasNextDueDate, nextDueDateOffsetDays
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) // completed (no next due, has completions)
f.Add(false, false, false, false, true, -5, false, 0) // overdue via DueDate
f.Add(false, false, false, false, false, 0, true, -5) // overdue via NextDueDate
f.Add(false, false, false, false, false, 0, true, 5) // due soon
f.Add(false, false, false, false, false, 0, true, 60) // upcoming
f.Add(true, true, true, true, true, -10, true, -10) // everything set
f.Add(false, false, false, false, true, 100, true, 100) // far future
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)
// Invariant 1: result must never be the empty string.
if result == "" {
t.Fatalf("CategorizeTask returned empty string for task %+v", task)
}
// Invariant 2: result must be one of the valid KanbanColumn constants.
if !validColumns[result] {
t.Fatalf("CategorizeTask returned invalid column %q for task %+v", result, task)
}
})
}
// TestCategorizeTask_MutuallyExclusive exhaustively enumerates all boolean
// state combinations (IsCancelled, IsArchived, InProgress, hasCompletions)
// crossed with representative date positions (no date, past, today, within
// threshold, beyond threshold) and asserts that every task maps to exactly
// one valid, non-empty column.
func TestCategorizeTask_MutuallyExclusive(t *testing.T) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
daysThreshold := 30
// Date scenarios relative to "now" for both DueDate and NextDueDate.
type dateScenario struct {
name string
dueDate *time.Time
nextDue *time.Time
}
past := now.AddDate(0, 0, -5)
today := now
withinThreshold := now.AddDate(0, 0, 10)
beyondThreshold := now.AddDate(0, 0, 60)
dateScenarios := []dateScenario{
{"no dates", nil, nil},
{"DueDate past only", &past, nil},
{"DueDate today only", &today, nil},
{"DueDate within threshold", &withinThreshold, nil},
{"DueDate beyond threshold", &beyondThreshold, nil},
{"NextDueDate past", nil, &past},
{"NextDueDate today", nil, &today},
{"NextDueDate within threshold", nil, &withinThreshold},
{"NextDueDate beyond threshold", nil, &beyondThreshold},
{"both past", &past, &past},
{"DueDate past NextDueDate future", &past, &withinThreshold},
{"both beyond threshold", &beyondThreshold, &beyondThreshold},
}
boolCombos := []struct {
cancelled, archived, inProgress, hasCompletions bool
}{
{false, false, false, false},
{true, false, false, false},
{false, true, false, false},
{false, false, true, false},
{false, false, false, true},
{true, true, false, false},
{true, false, true, false},
{true, false, false, true},
{false, true, true, false},
{false, true, false, true},
{false, false, true, true},
{true, true, true, false},
{true, true, false, true},
{true, false, true, true},
{false, true, true, true},
{true, true, true, true},
}
for _, ds := range dateScenarios {
for _, bc := range boolCombos {
task := &models.Task{
IsCancelled: bc.cancelled,
IsArchived: bc.archived,
InProgress: bc.inProgress,
DueDate: ds.dueDate,
NextDueDate: ds.nextDue,
}
if bc.hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
assert.NotEmpty(t, result,
"empty column for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
assert.True(t, validColumns[result],
"invalid column %q for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
result, ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
}
}
}
// TestCategorizeTask_CancelledAlwaysCancelled verifies the property that any
// task with IsCancelled=true is always categorized into ColumnCancelled,
// regardless of all other field values.
func TestCategorizeTask_CancelledAlwaysCancelled(t *testing.T) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
daysThreshold := 30
past := now.AddDate(0, 0, -5)
future := now.AddDate(0, 0, 10)
farFuture := now.AddDate(0, 0, 60)
dates := []*time.Time{nil, &past, &future, &farFuture}
bools := []bool{true, false}
for _, isArchived := range bools {
for _, inProgress := range bools {
for _, hasCompletions := range bools {
for _, dueDate := range dates {
for _, nextDueDate := range dates {
task := &models.Task{
IsCancelled: true, // always cancelled
IsArchived: isArchived,
InProgress: inProgress,
DueDate: dueDate,
NextDueDate: nextDueDate,
}
if hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
assert.Equal(t, categorization.ColumnCancelled, result,
"cancelled task should always map to ColumnCancelled, got %q "+
"(archived=%v inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
result, isArchived, inProgress, hasCompletions, dueDate, nextDueDate)
}
}
}
}
}
}
// TestCategorizeTask_ArchivedAlwaysArchived verifies the property that any
// task with IsArchived=true and IsCancelled=false is always categorized into
// ColumnCancelled (archived tasks share the cancelled column as both represent
// "inactive" states), regardless of all other field values.
func TestCategorizeTask_ArchivedAlwaysArchived(t *testing.T) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
daysThreshold := 30
past := now.AddDate(0, 0, -5)
future := now.AddDate(0, 0, 10)
farFuture := now.AddDate(0, 0, 60)
dates := []*time.Time{nil, &past, &future, &farFuture}
bools := []bool{true, false}
for _, inProgress := range bools {
for _, hasCompletions := range bools {
for _, dueDate := range dates {
for _, nextDueDate := range dates {
task := &models.Task{
IsCancelled: false, // not cancelled
IsArchived: true, // always archived
InProgress: inProgress,
DueDate: dueDate,
NextDueDate: nextDueDate,
}
if hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
assert.Equal(t, categorization.ColumnCancelled, result,
"archived (non-cancelled) task should always map to ColumnCancelled, got %q "+
"(inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
result, inProgress, hasCompletions, dueDate, nextDueDate)
}
}
}
}
}