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