package models import ( "encoding/json" "testing" "time" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) func TestTask_TableName(t *testing.T) { task := Task{} assert.Equal(t, "task_task", task.TableName()) } func TestTaskCategory_TableName(t *testing.T) { cat := TaskCategory{} assert.Equal(t, "task_taskcategory", cat.TableName()) } func TestTaskPriority_TableName(t *testing.T) { p := TaskPriority{} assert.Equal(t, "task_taskpriority", p.TableName()) } func TestTaskFrequency_TableName(t *testing.T) { f := TaskFrequency{} assert.Equal(t, "task_taskfrequency", f.TableName()) } func TestTaskCompletion_TableName(t *testing.T) { c := TaskCompletion{} assert.Equal(t, "task_taskcompletion", c.TableName()) } func TestContractor_TableName(t *testing.T) { c := Contractor{} assert.Equal(t, "task_contractor", c.TableName()) } func TestContractorSpecialty_TableName(t *testing.T) { s := ContractorSpecialty{} assert.Equal(t, "task_contractorspecialty", s.TableName()) } func TestDocument_TableName(t *testing.T) { d := Document{} assert.Equal(t, "task_document", d.TableName()) } func TestTask_JSONSerialization(t *testing.T) { dueDate := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) cost := decimal.NewFromFloat(150.50) task := Task{ ResidenceID: 1, CreatedByID: 1, Title: "Fix leaky faucet", Description: "Kitchen faucet is dripping", DueDate: &dueDate, EstimatedCost: &cost, IsCancelled: false, IsArchived: false, } task.ID = 1 data, err := json.Marshal(task) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(1), result["id"]) assert.Equal(t, float64(1), result["residence_id"]) assert.Equal(t, float64(1), result["created_by_id"]) assert.Equal(t, "Fix leaky faucet", result["title"]) assert.Equal(t, "Kitchen faucet is dripping", result["description"]) assert.Equal(t, "150.5", result["estimated_cost"]) // Decimal serializes as string assert.Equal(t, false, result["is_cancelled"]) assert.Equal(t, false, result["is_archived"]) } func TestTaskCategory_JSONSerialization(t *testing.T) { cat := TaskCategory{ Name: "Plumbing", Description: "Plumbing related tasks", Icon: "wrench", Color: "#3498db", DisplayOrder: 1, } cat.ID = 1 data, err := json.Marshal(cat) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(1), result["id"]) assert.Equal(t, "Plumbing", result["name"]) assert.Equal(t, "Plumbing related tasks", result["description"]) assert.Equal(t, "wrench", result["icon"]) assert.Equal(t, "#3498db", result["color"]) assert.Equal(t, float64(1), result["display_order"]) } func TestTaskPriority_JSONSerialization(t *testing.T) { priority := TaskPriority{ Name: "High", Level: 3, Color: "#e74c3c", DisplayOrder: 3, } priority.ID = 3 data, err := json.Marshal(priority) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(3), result["id"]) assert.Equal(t, "High", result["name"]) assert.Equal(t, float64(3), result["level"]) assert.Equal(t, "#e74c3c", result["color"]) } func TestTaskFrequency_JSONSerialization(t *testing.T) { days := 7 freq := TaskFrequency{ Name: "Weekly", Days: &days, DisplayOrder: 3, } freq.ID = 3 data, err := json.Marshal(freq) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(3), result["id"]) assert.Equal(t, "Weekly", result["name"]) assert.Equal(t, float64(7), result["days"]) } func TestTaskCompletion_JSONSerialization(t *testing.T) { completedAt := time.Date(2024, 6, 15, 14, 30, 0, 0, time.UTC) cost := decimal.NewFromFloat(125.00) completion := TaskCompletion{ TaskID: 1, CompletedByID: 2, CompletedAt: completedAt, Notes: "Fixed the leak", ActualCost: &cost, } completion.ID = 1 data, err := json.Marshal(completion) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(1), result["id"]) assert.Equal(t, float64(1), result["task_id"]) assert.Equal(t, float64(2), result["completed_by_id"]) assert.Equal(t, "Fixed the leak", result["notes"]) assert.Equal(t, "125", result["actual_cost"]) // Decimal serializes as string } func TestContractor_JSONSerialization(t *testing.T) { residenceID := uint(1) contractor := Contractor{ ResidenceID: &residenceID, CreatedByID: 1, Name: "Mike's Plumbing", Company: "Mike's Plumbing Co.", Phone: "+1-555-1234", Email: "mike@plumbing.com", Website: "https://mikesplumbing.com", Notes: "Great service", IsFavorite: true, IsActive: true, } contractor.ID = 1 data, err := json.Marshal(contractor) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(1), result["id"]) assert.Equal(t, float64(1), result["residence_id"]) assert.Equal(t, "Mike's Plumbing", result["name"]) assert.Equal(t, "Mike's Plumbing Co.", result["company"]) assert.Equal(t, "+1-555-1234", result["phone"]) assert.Equal(t, "mike@plumbing.com", result["email"]) assert.Equal(t, "https://mikesplumbing.com", result["website"]) assert.Equal(t, true, result["is_favorite"]) assert.Equal(t, true, result["is_active"]) } func TestDocument_JSONSerialization(t *testing.T) { purchaseDate := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC) expiryDate := time.Date(2028, 6, 15, 0, 0, 0, 0, time.UTC) price := decimal.NewFromFloat(5000.00) doc := Document{ ResidenceID: 1, CreatedByID: 1, Title: "HVAC Warranty", Description: "Warranty for central air", DocumentType: "warranty", FileURL: "/uploads/hvac.pdf", FileName: "hvac.pdf", PurchaseDate: &purchaseDate, ExpiryDate: &expiryDate, PurchasePrice: &price, Vendor: "Cool Air HVAC", SerialNumber: "HVAC-123", } doc.ID = 1 data, err := json.Marshal(doc) assert.NoError(t, err) var result map[string]interface{} err = json.Unmarshal(data, &result) assert.NoError(t, err) assert.Equal(t, float64(1), result["id"]) assert.Equal(t, "HVAC Warranty", result["title"]) assert.Equal(t, "warranty", result["document_type"]) assert.Equal(t, "/uploads/hvac.pdf", result["file_url"]) assert.Equal(t, "Cool Air HVAC", result["vendor"]) assert.Equal(t, "HVAC-123", result["serial_number"]) assert.Equal(t, "5000", result["purchase_price"]) // Decimal serializes as string } // ============================================================================ // TASK KANBAN COLUMN TESTS // These tests verify GetKanbanColumn and GetKanbanColumnWithTimezone methods // ============================================================================ func timePtr(t time.Time) *time.Time { return &t } func TestTask_GetKanbanColumn_PriorityOrder(t *testing.T) { now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC) yesterday := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) in5Days := time.Date(2025, 12, 21, 0, 0, 0, 0, time.UTC) in60Days := time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC) tests := []struct { name string task *Task expected string }{ // Priority 1: Cancelled { name: "cancelled takes highest priority", task: &Task{ IsCancelled: true, NextDueDate: timePtr(yesterday), InProgress: true, }, expected: "cancelled_tasks", }, // Priority 2: Completed { name: "completed: NextDueDate nil with completions", task: &Task{ IsCancelled: false, NextDueDate: nil, DueDate: timePtr(yesterday), Completions: []TaskCompletion{{BaseModel: BaseModel{ID: 1}}}, }, expected: "completed_tasks", }, // Priority 3: In Progress { name: "in progress takes priority over overdue", task: &Task{ IsCancelled: false, NextDueDate: timePtr(yesterday), InProgress: true, }, expected: "in_progress_tasks", }, // Priority 4: Overdue { name: "overdue: effective date in past", task: &Task{ IsCancelled: false, NextDueDate: timePtr(yesterday), }, expected: "overdue_tasks", }, // Priority 5: Due Soon { name: "due soon: within 30-day threshold", task: &Task{ IsCancelled: false, NextDueDate: timePtr(in5Days), }, expected: "due_soon_tasks", }, // Priority 6: Upcoming { name: "upcoming: beyond threshold", task: &Task{ IsCancelled: false, NextDueDate: timePtr(in60Days), }, expected: "upcoming_tasks", }, { name: "upcoming: no due date", task: &Task{ IsCancelled: false, NextDueDate: nil, DueDate: nil, }, expected: "upcoming_tasks", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.task.GetKanbanColumnWithTimezone(30, now) assert.Equal(t, tt.expected, result) }) } } func TestTask_GetKanbanColumnWithTimezone_TimezoneAware(t *testing.T) { // Task due Dec 17, 2025 taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC) task := &Task{ NextDueDate: timePtr(taskDueDate), IsCancelled: false, } // At 11 PM UTC on Dec 16 (UTC user) - task is tomorrow, due_soon utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC) result := task.GetKanbanColumnWithTimezone(30, utcDec16Evening) assert.Equal(t, "due_soon_tasks", result, "UTC Dec 16 evening") // At 8 AM UTC on Dec 17 (UTC user) - task is today, due_soon utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC) result = task.GetKanbanColumnWithTimezone(30, utcDec17Morning) assert.Equal(t, "due_soon_tasks", result, "UTC Dec 17 morning") // At 8 AM UTC on Dec 18 (UTC user) - task was yesterday, overdue utcDec18Morning := time.Date(2025, 12, 18, 8, 0, 0, 0, time.UTC) result = task.GetKanbanColumnWithTimezone(30, utcDec18Morning) assert.Equal(t, "overdue_tasks", result, "UTC Dec 18 morning") // Tokyo user at 11 PM UTC Dec 16 = 8 AM Dec 17 Tokyo // Task due Dec 17 is TODAY for Tokyo user - due_soon tokyo, _ := time.LoadLocation("Asia/Tokyo") tokyoDec17Morning := utcDec16Evening.In(tokyo) result = task.GetKanbanColumnWithTimezone(30, tokyoDec17Morning) assert.Equal(t, "due_soon_tasks", result, "Tokyo Dec 17 morning") // Tokyo at 8 AM Dec 18 UTC = 5 PM Dec 18 Tokyo // Task due Dec 17 was YESTERDAY for Tokyo - overdue tokyoDec18 := utcDec18Morning.In(tokyo) result = task.GetKanbanColumnWithTimezone(30, tokyoDec18) assert.Equal(t, "overdue_tasks", result, "Tokyo Dec 18") } func TestTask_GetKanbanColumnWithTimezone_DueSoonThreshold(t *testing.T) { now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC) // Task due in 29 days - within 30-day threshold due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC) task29 := &Task{NextDueDate: timePtr(due29Days)} result := task29.GetKanbanColumnWithTimezone(30, now) assert.Equal(t, "due_soon_tasks", result, "29 days should be due_soon") // Task due in exactly 30 days - at threshold boundary (upcoming, not due_soon) due30Days := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) task30 := &Task{NextDueDate: timePtr(due30Days)} result = task30.GetKanbanColumnWithTimezone(30, now) assert.Equal(t, "upcoming_tasks", result, "30 days should be upcoming (at boundary)") // Task due in 31 days - beyond threshold due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC) task31 := &Task{NextDueDate: timePtr(due31Days)} result = task31.GetKanbanColumnWithTimezone(30, now) assert.Equal(t, "upcoming_tasks", result, "31 days should be upcoming") } func TestTask_GetKanbanColumn_CompletionCount(t *testing.T) { // Test that CompletionCount is also used for completion detection task := &Task{ NextDueDate: nil, CompletionCount: 1, // Using CompletionCount instead of Completions slice Completions: []TaskCompletion{}, } result := task.GetKanbanColumn(30) assert.Equal(t, "completed_tasks", result) } func TestTask_IsOverdueAt_DayBased(t *testing.T) { // Test that IsOverdueAt uses day-based comparison now := time.Date(2025, 12, 16, 15, 0, 0, 0, time.UTC) // 3 PM UTC // Task due today (midnight) - NOT overdue todayMidnight := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC) taskToday := &Task{NextDueDate: timePtr(todayMidnight)} assert.False(t, taskToday.IsOverdueAt(now), "Task due today should NOT be overdue") // Task due yesterday - IS overdue yesterday := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) taskYesterday := &Task{NextDueDate: timePtr(yesterday)} assert.True(t, taskYesterday.IsOverdueAt(now), "Task due yesterday should be overdue") // Task due tomorrow - NOT overdue tomorrow := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC) taskTomorrow := &Task{NextDueDate: timePtr(tomorrow)} assert.False(t, taskTomorrow.IsOverdueAt(now), "Task due tomorrow should NOT be overdue") }