Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
542 lines
12 KiB
Go
542 lines
12 KiB
Go
package predicates_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-api/internal/task/predicates"
|
|
)
|
|
|
|
// Helper to create a time pointer
|
|
func timePtr(t time.Time) *time.Time {
|
|
return &t
|
|
}
|
|
|
|
func TestIsCompleted(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "completed: NextDueDate nil with completions",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not completed: NextDueDate set with completions",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)),
|
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not completed: NextDueDate nil without completions",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not completed: NextDueDate set without completions",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)),
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsCompleted(tt.task)
|
|
if result != tt.expected {
|
|
t.Errorf("IsCompleted() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsActive(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "active: not cancelled, not archived",
|
|
task: &models.Task{IsCancelled: false, IsArchived: false},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not active: cancelled",
|
|
task: &models.Task{IsCancelled: true, IsArchived: false},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not active: archived",
|
|
task: &models.Task{IsCancelled: false, IsArchived: true},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not active: both cancelled and archived",
|
|
task: &models.Task{IsCancelled: true, IsArchived: true},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsActive(tt.task)
|
|
if result != tt.expected {
|
|
t.Errorf("IsActive() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsInProgress(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "in progress: InProgress is true",
|
|
task: &models.Task{InProgress: true},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not in progress: InProgress is false",
|
|
task: &models.Task{InProgress: false},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsInProgress(tt.task)
|
|
if result != tt.expected {
|
|
t.Errorf("IsInProgress() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEffectiveDate(t *testing.T) {
|
|
now := time.Now()
|
|
nextWeek := now.AddDate(0, 0, 7)
|
|
nextMonth := now.AddDate(0, 1, 0)
|
|
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expected *time.Time
|
|
}{
|
|
{
|
|
name: "prefers NextDueDate when both set",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(nextWeek),
|
|
DueDate: timePtr(nextMonth),
|
|
},
|
|
expected: timePtr(nextWeek),
|
|
},
|
|
{
|
|
name: "falls back to DueDate when NextDueDate nil",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: timePtr(nextMonth),
|
|
},
|
|
expected: timePtr(nextMonth),
|
|
},
|
|
{
|
|
name: "returns nil when both nil",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: nil,
|
|
},
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.EffectiveDate(tt.task)
|
|
if tt.expected == nil {
|
|
if result != nil {
|
|
t.Errorf("EffectiveDate() = %v, expected nil", result)
|
|
}
|
|
} else {
|
|
if result == nil {
|
|
t.Errorf("EffectiveDate() = nil, expected %v", tt.expected)
|
|
} else if !result.Equal(*tt.expected) {
|
|
t.Errorf("EffectiveDate() = %v, expected %v", result, tt.expected)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsOverdue(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
yesterday := now.AddDate(0, 0, -1)
|
|
tomorrow := now.AddDate(0, 0, 1)
|
|
// Start of today - this is what a DATE column stores (midnight)
|
|
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
now time.Time
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "overdue: effective date in past",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(yesterday),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not overdue: effective date in future",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(tomorrow),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not overdue: task due today (start of day)",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(startOfToday),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now, // Current time during the day
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not overdue: cancelled task",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(yesterday),
|
|
IsCancelled: true,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not overdue: archived task",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(yesterday),
|
|
IsCancelled: false,
|
|
IsArchived: true,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not overdue: completed task (NextDueDate nil with completions)",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: timePtr(yesterday),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
|
|
},
|
|
now: now,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not overdue: no due date",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: nil,
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "overdue: uses DueDate when NextDueDate nil (no completions)",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: timePtr(yesterday),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsOverdue(tt.task, tt.now)
|
|
if result != tt.expected {
|
|
t.Errorf("IsOverdue() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDueSoon(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
yesterday := now.AddDate(0, 0, -1)
|
|
in5Days := now.AddDate(0, 0, 5)
|
|
in60Days := now.AddDate(0, 0, 60)
|
|
// Start of today - this is what a DATE column stores (midnight)
|
|
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
now time.Time
|
|
daysThreshold int
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "due soon: within threshold",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(in5Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "due soon: task due today (start of day)",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(startOfToday),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now, // Current time during the day
|
|
daysThreshold: 30,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not due soon: beyond threshold",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(in60Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not due soon: overdue (in past)",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(yesterday),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not due soon: cancelled",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(in5Days),
|
|
IsCancelled: true,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not due soon: completed",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: timePtr(in5Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold)
|
|
if result != tt.expected {
|
|
t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsUpcoming(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
in5Days := now.AddDate(0, 0, 5)
|
|
in60Days := now.AddDate(0, 0, 60)
|
|
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
now time.Time
|
|
daysThreshold int
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "upcoming: beyond threshold",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(in60Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "upcoming: no due date",
|
|
task: &models.Task{
|
|
NextDueDate: nil,
|
|
DueDate: nil,
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not upcoming: within due soon threshold",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(in5Days),
|
|
IsCancelled: false,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not upcoming: cancelled",
|
|
task: &models.Task{
|
|
NextDueDate: timePtr(in60Days),
|
|
IsCancelled: true,
|
|
IsArchived: false,
|
|
Completions: []models.TaskCompletion{},
|
|
},
|
|
now: now,
|
|
daysThreshold: 30,
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold)
|
|
if result != tt.expected {
|
|
t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasCompletions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "has completions",
|
|
task: &models.Task{Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "no completions",
|
|
task: &models.Task{Completions: []models.TaskCompletion{}},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "nil completions",
|
|
task: &models.Task{Completions: nil},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.HasCompletions(tt.task)
|
|
if result != tt.expected {
|
|
t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsRecurring(t *testing.T) {
|
|
days := 7
|
|
|
|
tests := []struct {
|
|
name string
|
|
task *models.Task
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "recurring: frequency with days",
|
|
task: &models.Task{Frequency: &models.TaskFrequency{Days: &days}},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "not recurring: frequency without days (one-time)",
|
|
task: &models.Task{Frequency: &models.TaskFrequency{Days: nil}},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "not recurring: no frequency",
|
|
task: &models.Task{Frequency: nil},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := predicates.IsRecurring(tt.task)
|
|
if result != tt.expected {
|
|
t.Errorf("IsRecurring() = %v, expected %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|