diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 3acf724..162f57c 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -24,6 +24,74 @@ import ( "gorm.io/gorm" ) +// ============ Response Structs for Type Safety ============ +// These avoid map[string]interface{} casts and provide better error messages + +// AuthResponse represents login/register response +type AuthResponse struct { + Token string `json:"token"` + User UserResponse `json:"user"` +} + +// UserResponse represents user data in responses +type UserResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` +} + +// DataWrapper wraps responses with a "data" field +type DataWrapper[T any] struct { + Data T `json:"data"` +} + +// TaskResponse represents task data in responses +type TaskResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + ResidenceID uint `json:"residence_id"` + KanbanColumn string `json:"kanban_column"` + InProgress bool `json:"in_progress"` + IsCancelled bool `json:"is_cancelled"` + IsArchived bool `json:"is_archived"` + DueDate *string `json:"due_date"` + NextDueDate *string `json:"next_due_date"` +} + +// KanbanResponse represents the kanban board response +type KanbanResponse struct { + Columns []KanbanColumnResponse `json:"columns"` + DaysThreshold int `json:"days_threshold"` + ResidenceID string `json:"residence_id"` +} + +// KanbanColumnResponse represents a single kanban column +type KanbanColumnResponse struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Count int `json:"count"` + Tasks []TaskResponse `json:"tasks"` +} + +// ResidenceResponse represents residence data +type ResidenceResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + IsPrimary bool `json:"is_primary"` +} + +// ShareCodeResponse represents share code generation response +type ShareCodeResponse struct { + ShareCode ShareCodeData `json:"share_code"` +} + +// ShareCodeData represents the share code object +type ShareCodeData struct { + Code string `json:"code"` + ExpiresAt string `json:"expires_at"` +} + // TestApp holds all components for integration testing type TestApp struct { DB *gorm.DB @@ -78,6 +146,9 @@ func setupIntegrationTest(t *testing.T) *TestApp { e.Validator = validator.NewCustomValidator() e.HTTPErrorHandler = apperrors.HTTPErrorHandler + // Add timezone middleware globally so X-Timezone header is processed + e.Use(middleware.TimezoneMiddleware()) + // Public routes auth := e.Group("/api/auth") { @@ -564,6 +635,9 @@ func TestIntegration_TasksByResidenceKanban(t *testing.T) { app := setupIntegrationTest(t) token := app.registerAndLogin(t, "owner", "owner@test.com", "password123") + // Use explicit timezone to test full timezone-aware path + testTimezone := "America/Los_Angeles" + // Create residence residenceBody := map[string]interface{}{"name": "Kanban House"} w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token) @@ -574,18 +648,18 @@ func TestIntegration_TasksByResidenceKanban(t *testing.T) { residenceData := residenceResp["data"].(map[string]interface{}) residenceID := uint(residenceData["id"].(float64)) - // Create multiple tasks + // Create multiple tasks with timezone header for i := 1; i <= 3; i++ { taskBody := map[string]interface{}{ "residence_id": residenceID, "title": "Task " + formatID(float64(i)), } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskBody, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) } - // Get tasks by residence (kanban view) - w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/by-residence/"+formatID(float64(residenceID)), nil, token) + // Get tasks by residence (kanban view) with timezone header + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", "/api/tasks/by-residence/"+formatID(float64(residenceID)), nil, token, testTimezone) assert.Equal(t, http.StatusOK, w.Code) var kanbanResp map[string]interface{} @@ -1010,9 +1084,15 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) { t.Log("✓ All 20 tasks verified") // ============ Phase 6: Kanban Verification Across 5 Timezones ============ - t.Log("Phase 6: Verifying kanban categorization across 5 timezones") + t.Log("Phase 6: Verifying kanban endpoint accepts X-Timezone header") // Test timezones spanning the extremes + // This test verifies: + // 1. The API correctly processes the X-Timezone header + // 2. The kanban structure is valid for each timezone + // + // Note: Whether categorization actually changes depends on test runtime. + // See TestIntegration_TimezoneDateBoundary for deterministic timezone behavior tests. timezones := []struct { name string location string @@ -1026,18 +1106,15 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) { } for _, tz := range timezones { - t.Logf(" Testing timezone: %s (%s)", tz.name, tz.offset) + t.Logf(" Testing with X-Timezone: %s (%s)", tz.location, tz.offset) loc, err := time.LoadLocation(tz.location) require.NoError(t, err, "Should load timezone: %s", tz.location) - - // Get current time in this timezone nowInTZ := time.Now().In(loc) - // Query kanban for first residence with timezone parameter - // Note: The API should accept timezone info via query param or header - // For now, we'll verify the kanban structure is correct - w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", residenceIDs[0]), nil, token) + // Query kanban WITH the X-Timezone header + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", + fmt.Sprintf("/api/tasks/by-residence/%d", residenceIDs[0]), nil, token, tz.location) require.Equal(t, http.StatusOK, w.Code) var kanbanResp map[string]interface{} @@ -1078,13 +1155,13 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) { assert.True(t, found, "Should have column: %s", colName) } - t.Logf(" ✓ Kanban structure verified at %s (%s)", nowInTZ.Format("2006-01-02 15:04"), tz.name) + t.Logf(" ✓ Kanban valid with X-Timezone=%s (local: %s)", tz.location, nowInTZ.Format("2006-01-02 15:04")) } - t.Log("✓ Kanban verification complete across all 5 timezones") + t.Log("✓ Kanban endpoint correctly processes X-Timezone header") // ============ Phase 7: Verify Task Distribution in Kanban Columns ============ - t.Log("Phase 7: Verifying task distribution in kanban columns") + t.Log("Phase 7: Verifying each task is in its expected kanban column") // Get full kanban view (all residences) w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks", nil, token) @@ -1093,52 +1170,54 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) { var fullKanban map[string]interface{} json.Unmarshal(w.Body.Bytes(), &fullKanban) - columns := fullKanban["columns"].([]interface{}) - columnCounts := make(map[string]int) - columnTasks := make(map[string][]string) - - for _, col := range columns { - column := col.(map[string]interface{}) - colName := column["name"].(string) - tasks := column["tasks"].([]interface{}) - columnCounts[colName] = len(tasks) - - for _, t := range tasks { - task := t.(map[string]interface{}) - columnTasks[colName] = append(columnTasks[colName], task["title"].(string)) - } - } - // Log distribution for debugging + columnCounts := getColumnCounts(fullKanban) t.Log(" Task distribution:") for colName, count := range columnCounts { t.Logf(" %s: %d tasks", colName, count) } - // Verify expected distributions based on task configs - // Note: Exact counts depend on current date relative to due dates - // We verify that: - // 1. Cancelled + Archived tasks are in cancelled_tasks column - // 2. Completed tasks are in completed_tasks column - // 3. In-progress tasks without overdue go to in_progress_tasks (unless overdue) - // 4. Active tasks are distributed based on due dates + // IDENTITY-BASED CORRECTNESS TEST + // Build map of column → actual task IDs from kanban response + columnTaskIDs := make(map[string][]uint) + columns := fullKanban["columns"].([]interface{}) + for _, col := range columns { + column := col.(map[string]interface{}) + colName := column["name"].(string) + tasks := column["tasks"].([]interface{}) + for _, task := range tasks { + taskMap := task.(map[string]interface{}) + columnTaskIDs[colName] = append(columnTaskIDs[colName], uint(taskMap["id"].(float64))) + } + } - // Verify cancelled/archived tasks - cancelledCount := columnCounts["cancelled_tasks"] - assert.GreaterOrEqual(t, cancelledCount, 4, "Should have at least 4 cancelled/archived tasks") + // Verify EACH task by ID is in its expected column + // This catches swaps where counts match but tasks are in wrong columns + t.Log(" Verifying each task's column membership by ID:") + for _, task := range createdTasks { + actualIDs := columnTaskIDs[task.ExpectedColumn] + found := false + for _, id := range actualIDs { + if id == task.ID { + found = true + break + } + } + assert.True(t, found, "Task ID %d ('%s') should be in column '%s' but was not found there. Column contains IDs: %v", + task.ID, task.Title, task.ExpectedColumn, actualIDs) + if found { + t.Logf(" ✓ Task %d ('%s') verified in '%s'", task.ID, task.Title, task.ExpectedColumn) + } + } - // Verify completed tasks - completedCount := columnCounts["completed_tasks"] - assert.GreaterOrEqual(t, completedCount, 3, "Should have at least 3 completed tasks") - - // Verify total equals 20 + // Verify total equals 20 (sanity check) total := 0 - for _, count := range columnCounts { - total += count + for _, ids := range columnTaskIDs { + total += len(ids) } assert.Equal(t, 20, total, "Total tasks across all columns should be 20") - t.Log("✓ Task distribution verified") + t.Log("✓ All 20 tasks verified in correct columns by ID") // ============ Phase 9: Create User B ============ t.Log("Phase 9: Creating User B and verifying login") @@ -1293,19 +1372,19 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) { t.Logf("✓ User B sees %d tasks for shared residence", userBTaskCount) // ============ Phase 13: Test User B Kanban Across Different Timezones ============ - t.Log("Phase 13: Verifying User B kanban across different timezones") + t.Log("Phase 13: Verifying User B kanban with X-Timezone header") - // Test that User B sees consistent kanban structure across timezones + // Test that User B's kanban endpoint accepts X-Timezone header for _, tz := range timezones { - t.Logf(" Testing User B in timezone: %s (%s)", tz.name, tz.offset) + t.Logf(" Testing User B with X-Timezone: %s (%s)", tz.location, tz.offset) loc, err := time.LoadLocation(tz.location) require.NoError(t, err) - nowInTZ := time.Now().In(loc) - // Get User B's kanban for shared residence - w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", sharedResidenceID), nil, tokenB) + // Get User B's kanban WITH X-Timezone header + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", + fmt.Sprintf("/api/tasks/by-residence/%d", sharedResidenceID), nil, tokenB, tz.location) require.Equal(t, http.StatusOK, w.Code) var tzKanbanResp map[string]interface{} @@ -1336,10 +1415,10 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) { assert.True(t, foundColumns[colName], "User B should have column: %s", colName) } - t.Logf(" ✓ User B kanban verified at %s (%s)", nowInTZ.Format("2006-01-02 15:04"), tz.name) + t.Logf(" ✓ User B kanban valid with X-Timezone=%s (local: %s)", tz.location, nowInTZ.Format("2006-01-02 15:04")) } - t.Log("✓ User B kanban verified across all timezones") + t.Log("✓ User B kanban correctly processes X-Timezone header") // ============ Phase 14: User A Creates Contractors, Verify User B Access ============ t.Log("Phase 14: User A creates contractors, verifying User B access") @@ -1527,6 +1606,9 @@ func setupContractorTest(t *testing.T) *TestApp { e.Validator = validator.NewCustomValidator() e.HTTPErrorHandler = apperrors.HTTPErrorHandler + // Add timezone middleware globally so X-Timezone header is processed + e.Use(middleware.TimezoneMiddleware()) + // Public routes auth := e.Group("/api/auth") { @@ -2159,9 +2241,13 @@ func TestIntegration_TaskStateTransitions(t *testing.T) { // ============ Test 4: Date Boundary Edge Cases ============ // TestIntegration_DateBoundaryEdgeCases tests edge cases around date boundaries: -// - Task due at 11:59 PM today (should be due_soon, not overdue) +// - Task due today (should be due_soon, not overdue) +// - Task due yesterday (should be overdue) // - Task due at threshold boundary (day 30) // - Task due at day 31 (should be upcoming) +// +// IMPORTANT: This test uses an explicit timezone (America/New_York) to ensure +// we're testing the full timezone-aware path, not just UTC defaults. func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { app := setupIntegrationTest(t) token := app.registerAndLogin(t, "boundary_user", "boundary@test.com", "password123") @@ -2176,10 +2262,19 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { residenceData := residenceResp["data"].(map[string]interface{}) residenceID := uint(residenceData["id"].(float64)) - now := time.Now().UTC() + // Use a specific timezone to test the full timezone-aware path + // All requests will use X-Timezone: America/New_York + testTimezone := "America/New_York" + loc, _ := time.LoadLocation(testTimezone) + now := time.Now().In(loc) + + // Due dates are stored as UTC midnight (calendar dates) + // "Today" in the test timezone determines what's overdue vs due_soon startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) threshold := 30 + t.Logf("Testing with timezone: %s (local date: %s)", testTimezone, now.Format("2006-01-02")) + t.Log("Phase 1: Task due today (should be due_soon, NOT overdue)") taskToday := map[string]interface{}{ @@ -2187,7 +2282,7 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { "title": "Due Today Task", "due_date": startOfToday.Format(time.RFC3339), } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskToday, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskToday, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) var todayResp map[string]interface{} @@ -2205,7 +2300,7 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { "title": "Due Yesterday Task", "due_date": startOfToday.AddDate(0, 0, -1).Format(time.RFC3339), } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskYesterday, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskYesterday, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) var yesterdayResp map[string]interface{} @@ -2223,7 +2318,7 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { "title": "Due in 29 Days Task", "due_date": startOfToday.AddDate(0, 0, threshold-1).Format(time.RFC3339), } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskDay29, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskDay29, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) var day29Resp map[string]interface{} @@ -2241,7 +2336,7 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { "title": "Due in 30 Days Task", "due_date": startOfToday.AddDate(0, 0, threshold).Format(time.RFC3339), } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskDay30, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskDay30, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) var day30Resp map[string]interface{} @@ -2259,7 +2354,7 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { "title": "Due in 31 Days Task", "due_date": startOfToday.AddDate(0, 0, threshold+1).Format(time.RFC3339), } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskDay31, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskDay31, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) var day31Resp map[string]interface{} @@ -2276,7 +2371,7 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { "residence_id": residenceID, "title": "No Due Date Task", } - w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskNoDue, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskNoDue, token, testTimezone) require.Equal(t, http.StatusCreated, w.Code) var noDueResp map[string]interface{} @@ -2286,10 +2381,10 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { assert.Equal(t, "upcoming_tasks", noDueData["kanban_column"], "Task with no due date should be upcoming") t.Log("✓ Task with no due date correctly in upcoming") - // Phase 7: Verify kanban distribution + // Phase 7: Verify kanban distribution (using same timezone) t.Log("Phase 7: Verify final kanban distribution") - w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, token) + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, token, testTimezone) require.Equal(t, http.StatusOK, w.Code) var kanbanResp map[string]interface{} @@ -2305,6 +2400,158 @@ func TestIntegration_DateBoundaryEdgeCases(t *testing.T) { t.Log("\n========== Date Boundary Edge Cases Test Complete ==========") } +// ============ Test 4b: Timezone Divergence ============ + +// TestIntegration_TimezoneDivergence proves that timezone affects task categorization. +// This is the key timezone behavior test - same task appears in different columns +// depending on the X-Timezone header. +// +// Strategy: Create a task due "today" (in UTC terms), then query from two timezones: +// - One where it's still "today" → task is due_soon +// - One where it's already "tomorrow" → task is overdue (due date was "yesterday") +func TestIntegration_TimezoneDivergence(t *testing.T) { + app := setupIntegrationTest(t) + token := app.registerAndLogin(t, "tz_user", "tz@test.com", "password123") + + // Create residence + residenceBody := map[string]interface{}{"name": "Timezone Test House"} + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token) + require.Equal(t, http.StatusCreated, w.Code) + + var residenceResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp) + residenceData := residenceResp["data"].(map[string]interface{}) + residenceID := uint(residenceData["id"].(float64)) + + // Find two timezones where the current date differs + // We need: timezone A where today = X, timezone B where today = X+1 + // This happens when: UTC time + offset(B) crosses midnight but UTC time + offset(A) doesn't + utcNow := time.Now().UTC() + + // Auckland is UTC+13 - if UTC hour >= 11, Auckland is on the next day + // Honolulu is UTC-10 - always same day or behind UTC + // + // If UTC is 14:00: + // - Honolulu: 04:00 same day (14-10=4) + // - Auckland: 03:00 next day (14+13-24=3) + // + // We'll use this to create a divergence scenario + + // Determine which date Auckland sees vs Honolulu + aucklandNow := utcNow.In(time.FixedZone("Auckland", 13*3600)) + honoluluNow := utcNow.In(time.FixedZone("Honolulu", -10*3600)) + + aucklandDate := aucklandNow.Format("2006-01-02") + honoluluDate := honoluluNow.Format("2006-01-02") + + t.Logf("Current times - UTC: %s, Auckland: %s, Honolulu: %s", + utcNow.Format("2006-01-02 15:04"), aucklandNow.Format("2006-01-02 15:04"), honoluluNow.Format("2006-01-02 15:04")) + + if aucklandDate == honoluluDate { + // Dates are the same - no divergence possible at this time + // This happens when UTC hour is between 00:00-10:59 + t.Logf("⚠️ Auckland and Honolulu see the same date (%s) - skipping divergence test", aucklandDate) + t.Log("This test requires UTC time >= 11:00 for date divergence") + + // Still verify the timezone header is processed correctly + taskBody := map[string]interface{}{ + "residence_id": residenceID, + "title": "Timezone Test Task", + "due_date": honoluluDate, + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token) + require.Equal(t, http.StatusCreated, w.Code) + + // Query with both timezones - should see same column + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", + fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, token, "Pacific/Auckland") + require.Equal(t, http.StatusOK, w.Code) + t.Log("✓ Timezone header accepted (no divergence scenario available at current UTC time)") + return + } + + // We have divergence! Auckland is on a later date than Honolulu + t.Logf("✓ Date divergence confirmed: Auckland=%s, Honolulu=%s", aucklandDate, honoluluDate) + + // Create a task due on the Honolulu date (the "earlier" date) + // In Honolulu: this is "today" → due_soon + // In Auckland: this is "yesterday" → overdue + taskBody := map[string]interface{}{ + "residence_id": residenceID, + "title": "Date Boundary Task", + "due_date": honoluluDate, // Due on the earlier date + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token) + require.Equal(t, http.StatusCreated, w.Code) + + var taskResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &taskResp) + taskData := taskResp["data"].(map[string]interface{}) + taskID := uint(taskData["id"].(float64)) + + t.Logf("Created task ID %d due on %s", taskID, honoluluDate) + + // Query from Honolulu timezone - task should be due_soon (due "today") + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", + fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, token, "Pacific/Honolulu") + require.Equal(t, http.StatusOK, w.Code) + + var honoluluKanban map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &honoluluKanban) + honoluluColumn := findTaskColumn(honoluluKanban, taskID) + + // Query from Auckland timezone - task should be overdue (due "yesterday") + w = app.makeAuthenticatedRequestWithTimezone(t, "GET", + fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, token, "Pacific/Auckland") + require.Equal(t, http.StatusOK, w.Code) + + var aucklandKanban map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &aucklandKanban) + aucklandColumn := findTaskColumn(aucklandKanban, taskID) + + t.Logf("Task column in Honolulu (where due_date is 'today'): %s", honoluluColumn) + t.Logf("Task column in Auckland (where due_date is 'yesterday'): %s", aucklandColumn) + + // THE KEY ASSERTION: Same task, different columns based on timezone + assert.Equal(t, "due_soon_tasks", honoluluColumn, + "Task due 'today' in Honolulu should be in due_soon_tasks") + assert.Equal(t, "overdue_tasks", aucklandColumn, + "Task due 'yesterday' in Auckland should be in overdue_tasks") + + // This proves timezone handling is working correctly + assert.NotEqual(t, honoluluColumn, aucklandColumn, + "CRITICAL: Same task must appear in DIFFERENT columns based on timezone") + + t.Log("✓ TIMEZONE DIVERGENCE VERIFIED: Same task categorizes differently based on X-Timezone header") + t.Logf(" - Honolulu (UTC-10, date=%s): %s", honoluluDate, honoluluColumn) + t.Logf(" - Auckland (UTC+13, date=%s): %s", aucklandDate, aucklandColumn) +} + +// findTaskColumn finds which column a task is in within a kanban response +func findTaskColumn(kanbanResp map[string]interface{}, taskID uint) string { + columns, ok := kanbanResp["columns"].([]interface{}) + if !ok { + return "NOT_FOUND" + } + + for _, col := range columns { + column := col.(map[string]interface{}) + colName := column["name"].(string) + tasks, ok := column["tasks"].([]interface{}) + if !ok { + continue + } + + for _, task := range tasks { + taskMap := task.(map[string]interface{}) + if uint(taskMap["id"].(float64)) == taskID { + return colName + } + } + } + return "NOT_FOUND" +} + // ============ Test 5: Cascade Operations ============ // TestIntegration_CascadeOperations tests what happens when residences/tasks are deleted: @@ -2608,3 +2855,146 @@ func getColumnCounts(kanbanResp map[string]interface{}) map[string]int { } return counts } + +// makeAuthenticatedRequestWithTimezone makes a request with the X-Timezone header set +func (app *TestApp) makeAuthenticatedRequestWithTimezone(t *testing.T, method, path string, body interface{}, token, timezone string) *httptest.ResponseRecorder { + var reqBody []byte + var err error + if body != nil { + reqBody, err = json.Marshal(body) + require.NoError(t, err) + } + + req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Token "+token) + } + if timezone != "" { + req.Header.Set("X-Timezone", timezone) + } + + w := httptest.NewRecorder() + app.Router.ServeHTTP(w, req) + return w +} + +// assertTaskInColumn verifies that a task with the given ID is in the expected kanban column +func assertTaskInColumn(t *testing.T, kanbanResp map[string]interface{}, taskID uint, expectedColumn string) { + t.Helper() + + columns, ok := kanbanResp["columns"].([]interface{}) + require.True(t, ok, "kanban response should have columns array") + + var foundColumn string + var found bool + + for _, col := range columns { + column := col.(map[string]interface{}) + colName := column["name"].(string) + tasks, ok := column["tasks"].([]interface{}) + if !ok { + continue + } + + for _, task := range tasks { + taskMap := task.(map[string]interface{}) + id := uint(taskMap["id"].(float64)) + if id == taskID { + foundColumn = colName + found = true + break + } + } + if found { + break + } + } + + require.True(t, found, "Task ID %d should be present in kanban columns", taskID) + assert.Equal(t, expectedColumn, foundColumn, "Task ID %d should be in column '%s' but found in '%s'", taskID, expectedColumn, foundColumn) +} + +// getTaskIDsInColumn returns all task IDs in a specific column +func getTaskIDsInColumn(kanbanResp map[string]interface{}, columnName string) []uint { + var ids []uint + + columns, ok := kanbanResp["columns"].([]interface{}) + if !ok { + return ids + } + + for _, col := range columns { + column := col.(map[string]interface{}) + colName := column["name"].(string) + if colName != columnName { + continue + } + + tasks, ok := column["tasks"].([]interface{}) + if !ok { + continue + } + + for _, task := range tasks { + taskMap := task.(map[string]interface{}) + ids = append(ids, uint(taskMap["id"].(float64))) + } + } + + return ids +} + +// ============ Type-Safe Assertion Helpers ============ + +// assertTaskInColumnTyped verifies a task is in the expected column using typed structs +// Use this for better error messages and type safety +func assertTaskInColumnTyped(t *testing.T, kanban *KanbanResponse, taskID uint, expectedColumn string) { + t.Helper() + + var foundColumn string + var found bool + + for _, column := range kanban.Columns { + for _, task := range column.Tasks { + if task.ID == taskID { + foundColumn = column.Name + found = true + break + } + } + if found { + break + } + } + + require.True(t, found, "Task ID %d should be present in kanban columns", taskID) + assert.Equal(t, expectedColumn, foundColumn, "Task ID %d should be in column '%s' but found in '%s'", taskID, expectedColumn, foundColumn) +} + +// parseKanbanResponse parses a kanban response with proper error handling +func parseKanbanResponse(t *testing.T, w *httptest.ResponseRecorder) *KanbanResponse { + t.Helper() + var resp KanbanResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err, "Failed to parse kanban response: %s", w.Body.String()) + return &resp +} + +// parseTaskResponse parses a task response wrapped in {"data": ...} +func parseTaskResponse(t *testing.T, w *httptest.ResponseRecorder) *TaskResponse { + t.Helper() + var resp DataWrapper[TaskResponse] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err, "Failed to parse task response: %s", w.Body.String()) + return &resp.Data +} + +// parseAuthResponse parses an auth response (login/register) +func parseAuthResponse(t *testing.T, w *httptest.ResponseRecorder) *AuthResponse { + t.Helper() + var resp AuthResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err, "Failed to parse auth response: %s", w.Body.String()) + return &resp +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 87e68ae..97a47ae 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -110,10 +110,11 @@ func (r *TaskRepository) GetOverdueTasks(now time.Time, opts TaskFilterOptions) if opts.IncludeArchived { // When including archived, build the query manually to skip the archived check // but still apply cancelled check, not-completed check, and date check - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + // IMPORTANT: Use date comparison (not timestamps) for timezone correctness + todayStr := now.Format("2006-01-02") query = query.Where("is_cancelled = ?", false). Scopes(task.ScopeNotCompleted). - Where("COALESCE(next_due_date, due_date) < ?", startOfDay) + Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr) } else { // Use the combined scope which includes is_archived = false query = query.Scopes(task.ScopeOverdue(now)) diff --git a/internal/repositories/task_repo_test.go b/internal/repositories/task_repo_test.go index ff1afd5..71f5cc1 100644 --- a/internal/repositories/task_repo_test.go +++ b/internal/repositories/task_repo_test.go @@ -1493,9 +1493,10 @@ func TestGetOverdueTasks_Timezone_Tokyo(t *testing.T) { tokyo, _ := time.LoadLocation("Asia/Tokyo") - // Task due on Dec 15 at midnight in Tokyo timezone - // When stored as UTC, this becomes Dec 14 15:00 UTC - dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, tokyo) + // IMPORTANT: Due dates are stored as midnight UTC representing calendar dates. + // A task "due on Dec 15" is stored as 2025-12-15 00:00:00 UTC. + // Timezone handling happens at query time via the `now` parameter. + dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, @@ -1506,13 +1507,14 @@ func TestGetOverdueTasks_Timezone_Tokyo(t *testing.T) { opts := TaskFilterOptions{ResidenceID: residence.ID} - // When it's Dec 15 23:00 in Tokyo, task should NOT be overdue (still Dec 15) + // When it's Dec 15 23:00 in Tokyo, task should NOT be overdue (still Dec 15 in Tokyo) + // The `now` parameter's date (Dec 15) is compared against the due date (Dec 15) tokyoDec15Evening := time.Date(2025, 12, 15, 23, 0, 0, 0, tokyo) tasks, err := repo.GetOverdueTasks(tokyoDec15Evening, opts) require.NoError(t, err) assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15 in Tokyo timezone") - // When it's Dec 16 00:00 in Tokyo, task IS overdue (now Dec 16) + // When it's Dec 16 00:00 in Tokyo, task IS overdue (now Dec 16 in Tokyo, due Dec 15) tokyoDec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, tokyo) tasks, err = repo.GetOverdueTasks(tokyoDec16Midnight, opts) require.NoError(t, err) @@ -1528,9 +1530,10 @@ func TestGetOverdueTasks_Timezone_NewYork(t *testing.T) { newYork, _ := time.LoadLocation("America/New_York") - // Task due on Dec 15 at midnight in New York timezone - // When stored as UTC, this becomes Dec 15 05:00 UTC (EST is UTC-5) - dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, newYork) + // IMPORTANT: Due dates are stored as midnight UTC representing calendar dates. + // A task "due on Dec 15" is stored as 2025-12-15 00:00:00 UTC. + // Timezone handling happens at query time via the `now` parameter. + dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, @@ -1541,13 +1544,13 @@ func TestGetOverdueTasks_Timezone_NewYork(t *testing.T) { opts := TaskFilterOptions{ResidenceID: residence.ID} - // When it's Dec 15 23:00 in New York, task should NOT be overdue + // When it's Dec 15 23:00 in New York, task should NOT be overdue (still Dec 15 in NY) nyDec15Evening := time.Date(2025, 12, 15, 23, 0, 0, 0, newYork) tasks, err := repo.GetOverdueTasks(nyDec15Evening, opts) require.NoError(t, err) assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15 in New York timezone") - // When it's Dec 16 00:00 in New York, task IS overdue + // When it's Dec 16 00:00 in New York, task IS overdue (now Dec 16 in NY, due Dec 15) nyDec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, newYork) tasks, err = repo.GetOverdueTasks(nyDec16Midnight, opts) require.NoError(t, err) @@ -1566,8 +1569,9 @@ func TestGetOverdueTasks_Timezone_InternationalDateLine(t *testing.T) { auckland, _ := time.LoadLocation("Pacific/Auckland") honolulu, _ := time.LoadLocation("Pacific/Honolulu") - // Task due on Dec 15 at midnight Auckland time - dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, auckland) + // IMPORTANT: Due dates are stored as midnight UTC representing calendar dates. + // Task due on Dec 15 is stored as 2025-12-15 00:00:00 UTC. + dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, @@ -1578,14 +1582,13 @@ func TestGetOverdueTasks_Timezone_InternationalDateLine(t *testing.T) { opts := TaskFilterOptions{ResidenceID: residence.ID} - // From Auckland's perspective on Dec 16, task is overdue + // From Auckland's perspective on Dec 16, task is overdue (Dec 16 > Dec 15) aucklandDec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, auckland) tasks, err := repo.GetOverdueTasks(aucklandDec16, opts) require.NoError(t, err) assert.Len(t, tasks, 1, "Task should be overdue on Dec 16 in Auckland") - // From Honolulu's perspective on Dec 14 (same UTC instant as Auckland Dec 15 morning), - // it's still before the due date, so NOT overdue + // From Honolulu's perspective on Dec 14, task is NOT overdue (Dec 14 < Dec 15) honoluluDec14 := time.Date(2025, 12, 14, 5, 0, 0, 0, honolulu) tasks, err = repo.GetOverdueTasks(honoluluDec14, opts) require.NoError(t, err) @@ -1601,9 +1604,10 @@ func TestGetDueSoonTasks_Timezone_DST(t *testing.T) { newYork, _ := time.LoadLocation("America/New_York") + // IMPORTANT: Due dates are stored as midnight UTC representing calendar dates. // 2025 DST ends Nov 2: clocks fall back from 2:00 AM to 1:00 AM - // Task due on Nov 5 at midnight in New York timezone - dueDate := time.Date(2025, 11, 5, 0, 0, 0, 0, newYork) + // Task due on Nov 5 is stored as 2025-11-05 00:00:00 UTC + dueDate := time.Date(2025, 11, 5, 0, 0, 0, 0, time.UTC) task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, diff --git a/internal/task/scopes/scopes.go b/internal/task/scopes/scopes.go index 656da59..40c58c0 100644 --- a/internal/task/scopes/scopes.go +++ b/internal/task/scopes/scopes.go @@ -104,13 +104,18 @@ func ScopeNotInProgress(db *gorm.DB) *gorm.DB { // // Predicate equivalent: IsOverdue(task, now) // -// SQL: COALESCE(next_due_date, due_date) < ? AND active AND not_completed +// IMPORTANT: Due dates are stored as midnight UTC but represent calendar dates. +// We compare dates (YYYY-MM-DD), not timestamps, to ensure timezone correctness. +// A task due "2024-12-16" should only be overdue when the user's date is "2024-12-17" or later. +// +// Uses DATE() function which works in both PostgreSQL and SQLite. func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { - // Compute start of day in Go for database-agnostic comparison - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + // Extract user's current date as a string for date-only comparison + // This ensures timezone correctness: the user's "today" determines overdue status + todayStr := now.Format("2006-01-02") return db.Scopes(ScopeActive, ScopeNotCompleted). - Where("COALESCE(next_due_date, due_date) < ?", startOfDay) + Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr) } } @@ -123,17 +128,16 @@ func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB { // // Predicate equivalent: IsDueSoon(task, now, daysThreshold) // -// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ? -// -// AND active AND not_completed +// IMPORTANT: Uses date comparison (not timestamps) for timezone correctness. func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { - // Compute start of day and threshold in Go for database-agnostic comparison - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - threshold := startOfDay.AddDate(0, 0, daysThreshold) + // Extract dates as strings for date-only comparison + todayStr := now.Format("2006-01-02") + thresholdDate := now.AddDate(0, 0, daysThreshold) + thresholdStr := thresholdDate.Format("2006-01-02") return db.Scopes(ScopeActive, ScopeNotCompleted). - Where("COALESCE(next_due_date, due_date) >= ?", startOfDay). - Where("COALESCE(next_due_date, due_date) < ?", threshold) + Where("DATE(COALESCE(next_due_date, due_date)) >= DATE(?)", todayStr). + Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", thresholdStr) } } @@ -142,34 +146,32 @@ func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { // A task is "upcoming" when its effective date is >= start of (today + threshold) OR is null, // and it's active and not completed. // -// Note: Uses start of day for comparisons for consistency with other scopes. -// // Predicate equivalent: IsUpcoming(task, now, daysThreshold) // -// SQL: (COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL)) -// -// AND active AND not_completed +// IMPORTANT: Uses date comparison (not timestamps) for timezone correctness. func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { - // Compute threshold as start of day + N days in Go for database-agnostic comparison - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - threshold := startOfDay.AddDate(0, 0, daysThreshold) + // Compute threshold date + thresholdDate := now.AddDate(0, 0, daysThreshold) + thresholdStr := thresholdDate.Format("2006-01-02") return db.Scopes(ScopeActive, ScopeNotCompleted). Where( - "COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL)", - threshold, + "DATE(COALESCE(next_due_date, due_date)) >= DATE(?) OR (next_due_date IS NULL AND due_date IS NULL)", + thresholdStr, ) } } // ScopeDueInRange returns a scope for tasks with effective date in a range. // -// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ? +// IMPORTANT: Uses date comparison (not timestamps) for timezone correctness. func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { + startStr := start.Format("2006-01-02") + endStr := end.Format("2006-01-02") return db. - Where("COALESCE(next_due_date, due_date) >= ?", start). - Where("COALESCE(next_due_date, due_date) < ?", end) + Where("DATE(COALESCE(next_due_date, due_date)) >= DATE(?)", startStr). + Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", endStr) } }