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

@@ -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)
}
}
}
}
}