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:
241
internal/task/categorization/chain_breakit_test.go
Normal file
241
internal/task/categorization/chain_breakit_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user