Files
honeyDueAPI/internal/integration/integration_test.go
Trey t 215e7c895d wip
2026-02-18 10:54:18 -06:00

3025 lines
112 KiB
Go

package integration
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/handlers"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/testutil"
"github.com/treytartt/casera-api/internal/validator"
"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
Router *echo.Echo
AuthHandler *handlers.AuthHandler
ResidenceHandler *handlers.ResidenceHandler
TaskHandler *handlers.TaskHandler
ContractorHandler *handlers.ContractorHandler
UserRepo *repositories.UserRepository
ResidenceRepo *repositories.ResidenceRepository
TaskRepo *repositories.TaskRepository
ContractorRepo *repositories.ContractorRepository
AuthService *services.AuthService
}
func setupIntegrationTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
// Create repositories
userRepo := repositories.NewUserRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
// Create config
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
// Create services
authService := services.NewAuthService(userRepo, cfg)
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
taskService := services.NewTaskService(taskRepo, residenceRepo)
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
// Create handlers
authHandler := handlers.NewAuthHandler(authService, nil, nil)
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil)
taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware
e := echo.New()
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")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes - use AuthMiddleware without Redis cache for testing
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
residences.GET("", residenceHandler.ListResidences)
residences.POST("", residenceHandler.CreateResidence)
residences.GET("/:id", residenceHandler.GetResidence)
residences.PUT("/:id", residenceHandler.UpdateResidence)
residences.DELETE("/:id", residenceHandler.DeleteResidence)
residences.POST("/:id/generate-share-code", residenceHandler.GenerateShareCode)
residences.GET("/:id/users", residenceHandler.GetResidenceUsers)
residences.DELETE("/:id/users/:user_id", residenceHandler.RemoveResidenceUser)
}
api.POST("/residences/join-with-code", residenceHandler.JoinWithCode)
api.GET("/residence-types", residenceHandler.GetResidenceTypes)
tasks := api.Group("/tasks")
{
tasks.GET("", taskHandler.ListTasks)
tasks.POST("", taskHandler.CreateTask)
tasks.GET("/:id", taskHandler.GetTask)
tasks.PUT("/:id", taskHandler.UpdateTask)
tasks.DELETE("/:id", taskHandler.DeleteTask)
tasks.POST("/:id/cancel", taskHandler.CancelTask)
tasks.POST("/:id/uncancel", taskHandler.UncancelTask)
tasks.POST("/:id/archive", taskHandler.ArchiveTask)
tasks.POST("/:id/unarchive", taskHandler.UnarchiveTask)
tasks.POST("/:id/mark-in-progress", taskHandler.MarkInProgress)
}
api.GET("/tasks/by-residence/:residence_id", taskHandler.GetTasksByResidence)
completions := api.Group("/completions")
{
completions.GET("", taskHandler.ListCompletions)
completions.POST("", taskHandler.CreateCompletion)
completions.GET("/:id", taskHandler.GetCompletion)
completions.DELETE("/:id", taskHandler.DeleteCompletion)
}
api.GET("/task-categories", taskHandler.GetCategories)
api.GET("/task-priorities", taskHandler.GetPriorities)
api.GET("/task-frequencies", taskHandler.GetFrequencies)
contractors := api.Group("/contractors")
{
contractors.GET("", contractorHandler.ListContractors)
contractors.POST("", contractorHandler.CreateContractor)
contractors.GET("/:id", contractorHandler.GetContractor)
contractors.PUT("/:id", contractorHandler.UpdateContractor)
contractors.DELETE("/:id", contractorHandler.DeleteContractor)
}
api.GET("/contractors/by-residence/:residence_id", contractorHandler.ListContractorsByResidence)
}
return &TestApp{
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
}
// Helper to make authenticated requests
func (app *TestApp) makeAuthenticatedRequest(t *testing.T, method, path string, body interface{}, token 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)
}
w := httptest.NewRecorder()
app.Router.ServeHTTP(w, req)
return w
}
// Helper to register and login a user, returns token
func (app *TestApp) registerAndLogin(t *testing.T, username, email, password string) string {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
return loginResp["token"].(string)
}
// ============ Authentication Flow Tests ============
func TestIntegration_AuthenticationFlow(t *testing.T) {
app := setupIntegrationTest(t)
// 1. Register a new user
registerBody := map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePass123!",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
assert.Equal(t, http.StatusCreated, w.Code)
var registerResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &registerResp)
require.NoError(t, err)
assert.NotEmpty(t, registerResp["token"])
assert.NotNil(t, registerResp["user"])
// 2. Login with the same credentials
loginBody := map[string]string{
"username": "testuser",
"password": "SecurePass123!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
assert.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
assert.NotEmpty(t, token)
// 3. Get current user with token
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var meResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &meResp)
require.NoError(t, err)
assert.Equal(t, "testuser", meResp["username"])
assert.Equal(t, "test@example.com", meResp["email"])
// 4. Access protected route without token should fail
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "")
assert.Equal(t, http.StatusUnauthorized, w.Code)
// 5. Access protected route with invalid token should fail
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "invalid-token")
assert.Equal(t, http.StatusUnauthorized, w.Code)
// 6. Logout
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/logout", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestIntegration_RegistrationValidation(t *testing.T) {
app := setupIntegrationTest(t)
tests := []struct {
name string
body map[string]string
expectedStatus int
}{
{
name: "missing username",
body: map[string]string{"email": "test@example.com", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
{
name: "missing email",
body: map[string]string{"username": "testuser", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
{
name: "missing password",
body: map[string]string{"username": "testuser", "email": "test@example.com"},
expectedStatus: http.StatusBadRequest,
},
{
name: "invalid email",
body: map[string]string{"username": "testuser", "email": "invalid", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", tt.body, "")
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestIntegration_DuplicateRegistration(t *testing.T) {
app := setupIntegrationTest(t)
// Register first user (password must be >= 8 chars)
registerBody := map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
assert.Equal(t, http.StatusCreated, w.Code)
// Try to register with same username - returns 409 (Conflict)
registerBody2 := map[string]string{
"username": "testuser",
"email": "different@example.com",
"password": "password123",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody2, "")
assert.Equal(t, http.StatusConflict, w.Code)
// Try to register with same email - returns 409 (Conflict)
registerBody3 := map[string]string{
"username": "differentuser",
"email": "test@example.com",
"password": "password123",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody3, "")
assert.Equal(t, http.StatusConflict, w.Code)
}
// ============ Residence Flow Tests ============
func TestIntegration_ResidenceFlow(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "owner", "owner@test.com", "password123")
// 1. Create a residence
createBody := map[string]interface{}{
"name": "My House",
"street_address": "123 Main St",
"city": "Austin",
"state_province": "TX",
"postal_code": "78701",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", createBody, token)
assert.Equal(t, http.StatusCreated, w.Code)
var createResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &createResp)
require.NoError(t, err)
createData := createResp["data"].(map[string]interface{})
residenceID := createData["id"].(float64)
assert.NotZero(t, residenceID)
assert.Equal(t, "My House", createData["name"])
assert.True(t, createData["is_primary"].(bool))
// 2. Get the residence
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var getResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &getResp)
require.NoError(t, err)
assert.Equal(t, "My House", getResp["name"])
// 3. List residences
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var listResp []map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &listResp)
require.NoError(t, err)
assert.Len(t, listResp, 1)
// 4. Update the residence
updateBody := map[string]interface{}{
"name": "My Updated House",
"city": "Dallas",
}
w = app.makeAuthenticatedRequest(t, "PUT", "/api/residences/"+formatID(residenceID), updateBody, token)
assert.Equal(t, http.StatusOK, w.Code)
var updateResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &updateResp)
require.NoError(t, err)
updateData := updateResp["data"].(map[string]interface{})
assert.Equal(t, "My Updated House", updateData["name"])
assert.Equal(t, "Dallas", updateData["city"])
// 5. Delete the residence (returns 200 with message, not 204)
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, token)
assert.Equal(t, http.StatusOK, w.Code)
// 6. Verify it's deleted (should return 403 - access denied since it doesn't exist/inactive)
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestIntegration_ResidenceSharingFlow(t *testing.T) {
app := setupIntegrationTest(t)
// Create owner and another user
ownerToken := app.registerAndLogin(t, "owner", "owner@test.com", "password123")
userToken := app.registerAndLogin(t, "shareduser", "shared@test.com", "password123")
// Create residence as owner
createBody := map[string]interface{}{
"name": "Shared House",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", createBody, ownerToken)
require.Equal(t, http.StatusCreated, w.Code)
var createResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &createResp)
createData := createResp["data"].(map[string]interface{})
residenceID := createData["id"].(float64)
// Other user cannot access initially
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken)
assert.Equal(t, http.StatusForbidden, w.Code)
// Generate share code
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceID)+"/generate-share-code", nil, ownerToken)
assert.Equal(t, http.StatusOK, w.Code)
var shareResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &shareResp)
require.NoError(t, err)
shareCodeObj, ok := shareResp["share_code"].(map[string]interface{})
require.True(t, ok, "Expected share_code object in response")
shareCode := shareCodeObj["code"].(string)
assert.Len(t, shareCode, 6)
// User joins with code
joinBody := map[string]interface{}{
"code": shareCode,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", joinBody, userToken)
assert.Equal(t, http.StatusOK, w.Code)
// Now user can access
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken)
assert.Equal(t, http.StatusOK, w.Code)
// Get users list - returns array directly, not wrapped in {"users": ...}
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID)+"/users", nil, ownerToken)
assert.Equal(t, http.StatusOK, w.Code)
var users []interface{}
err = json.Unmarshal(w.Body.Bytes(), &users)
require.NoError(t, err)
assert.Len(t, users, 2) // owner + shared user
}
// ============ Task Flow Tests ============
func TestIntegration_TaskFlow(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "owner", "owner@test.com", "password123")
// Create residence first
residenceBody := map[string]interface{}{"name": "Task 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))
// 1. Create a task
taskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "Fix leaky faucet",
"description": "Kitchen faucet is dripping",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token)
assert.Equal(t, http.StatusCreated, w.Code)
var taskResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskResp)
taskData := taskResp["data"].(map[string]interface{})
taskID := taskData["id"].(float64)
assert.NotZero(t, taskID)
assert.Equal(t, "Fix leaky faucet", taskData["title"])
// 2. Get the task
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, token)
assert.Equal(t, http.StatusOK, w.Code)
// 3. Update the task
updateBody := map[string]interface{}{
"title": "Fix kitchen faucet",
"description": "Updated description",
}
w = app.makeAuthenticatedRequest(t, "PUT", "/api/tasks/"+formatID(taskID), updateBody, token)
assert.Equal(t, http.StatusOK, w.Code)
var taskUpdateResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskUpdateResp)
taskUpdateData := taskUpdateResp["data"].(map[string]interface{})
assert.Equal(t, "Fix kitchen faucet", taskUpdateData["title"])
// 4. Mark as in progress
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/mark-in-progress", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var progressResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &progressResp)
progressData := progressResp["data"].(map[string]interface{})
assert.True(t, progressData["in_progress"].(bool))
// 5. Complete the task
completionBody := map[string]interface{}{
"task_id": taskID,
"notes": "Fixed the faucet",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
assert.Equal(t, http.StatusCreated, w.Code)
var completionResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &completionResp)
completionData := completionResp["data"].(map[string]interface{})
completionID := completionData["id"].(float64)
assert.NotZero(t, completionID)
assert.Equal(t, "Fixed the faucet", completionData["notes"])
// 6. List completions
w = app.makeAuthenticatedRequest(t, "GET", "/api/completions?task_id="+formatID(taskID), nil, token)
assert.Equal(t, http.StatusOK, w.Code)
// 7. Archive the task
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/archive", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var archiveResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &archiveResp)
archivedData := archiveResp["data"].(map[string]interface{})
assert.True(t, archivedData["is_archived"].(bool))
// 8. Unarchive the task
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/unarchive", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
// 9. Cancel the task
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/cancel", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var cancelResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &cancelResp)
cancelledData := cancelResp["data"].(map[string]interface{})
assert.True(t, cancelledData["is_cancelled"].(bool))
// 10. Delete the task (returns 200 with message, not 204)
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/tasks/"+formatID(taskID), nil, token)
assert.Equal(t, http.StatusOK, w.Code)
}
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)
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))
// 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.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskBody, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
}
// 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{}
json.Unmarshal(w.Body.Bytes(), &kanbanResp)
columns := kanbanResp["columns"].([]interface{})
assert.Greater(t, len(columns), 0)
// Check column structure
for _, col := range columns {
column := col.(map[string]interface{})
assert.NotEmpty(t, column["name"])
assert.NotEmpty(t, column["display_name"])
assert.NotNil(t, column["tasks"])
assert.NotNil(t, column["count"])
}
}
// ============ Lookup Data Tests ============
func TestIntegration_LookupEndpoints(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "user", "user@test.com", "password123")
tests := []struct {
name string
endpoint string
}{
{"residence types", "/api/residence-types"},
{"task categories", "/api/task-categories"},
{"task priorities", "/api/task-priorities"},
{"task frequencies", "/api/task-frequencies"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := app.makeAuthenticatedRequest(t, "GET", tt.endpoint, nil, token)
assert.Equal(t, http.StatusOK, w.Code)
// All lookup endpoints return arrays directly
var items []interface{}
err := json.Unmarshal(w.Body.Bytes(), &items)
require.NoError(t, err)
assert.Greater(t, len(items), 0)
// Check item structure
for _, item := range items {
obj := item.(map[string]interface{})
assert.NotZero(t, obj["id"])
assert.NotEmpty(t, obj["name"])
}
})
}
}
// ============ Access Control Tests ============
func TestIntegration_CrossUserAccessDenied(t *testing.T) {
app := setupIntegrationTest(t)
// Create two users with their own residences
user1Token := app.registerAndLogin(t, "user1", "user1@test.com", "password123")
user2Token := app.registerAndLogin(t, "user2", "user2@test.com", "password123")
// User1 creates a residence
residenceBody := map[string]interface{}{"name": "User1's House"}
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, user1Token)
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 := residenceData["id"].(float64)
// User1 creates a task
taskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "User1's Task",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, user1Token)
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 := taskData["id"].(float64)
// User2 cannot access User1's residence
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, user2Token)
assert.Equal(t, http.StatusForbidden, w.Code)
// User2 cannot access User1's task
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, user2Token)
assert.Equal(t, http.StatusForbidden, w.Code)
// User2 cannot update User1's residence
updateBody := map[string]interface{}{"name": "Hacked!"}
w = app.makeAuthenticatedRequest(t, "PUT", "/api/residences/"+formatID(residenceID), updateBody, user2Token)
assert.Equal(t, http.StatusForbidden, w.Code)
// User2 cannot delete User1's residence
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, user2Token)
assert.Equal(t, http.StatusForbidden, w.Code)
// User2 cannot create task in User1's residence
taskBody2 := map[string]interface{}{
"residence_id": residenceID,
"title": "Malicious Task",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody2, user2Token)
assert.Equal(t, http.StatusForbidden, w.Code)
}
// ============ JSON Response Structure Tests ============
func TestIntegration_ResponseStructure(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "user", "user@test.com", "password123")
// Create residence
residenceBody := map[string]interface{}{
"name": "Response Test House",
"street_address": "123 Test St",
"city": "Austin",
"state_province": "TX",
"postal_code": "78701",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token)
require.Equal(t, http.StatusCreated, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// Response is wrapped with "data" and "summary"
data := resp["data"].(map[string]interface{})
_, hasSummary := resp["summary"]
assert.True(t, hasSummary, "Expected 'summary' field in response")
// Verify all expected fields are present in data
expectedFields := []string{
"id", "owner_id", "name", "street_address", "city",
"state_province", "postal_code", "country",
"is_primary", "is_active", "created_at", "updated_at",
}
for _, field := range expectedFields {
_, exists := data[field]
assert.True(t, exists, "Expected field %s to be present in data", field)
}
// Check that nullable fields can be null
assert.Nil(t, data["bedrooms"])
assert.Nil(t, data["bathrooms"])
}
// ============ Comprehensive E2E Test ============
// TestIntegration_ComprehensiveE2E is a full end-to-end test that:
// 1. Registers a new user and verifies login
// 2. Creates 5 residences
// 3. Creates 20 tasks in different statuses across residences
// 4. Verifies residences return correctly
// 5. Verifies tasks return correctly
// 6. Verifies kanban categorization across 5 timezones
func TestIntegration_ComprehensiveE2E(t *testing.T) {
app := setupIntegrationTest(t)
// ============ Phase 1: Authentication ============
t.Log("Phase 1: Testing authentication flow")
// Register new user
registerBody := map[string]string{
"username": "e2e_testuser",
"email": "e2e@example.com",
"password": "SecurePass123!",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed")
var registerResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &registerResp)
require.NoError(t, err)
assert.NotEmpty(t, registerResp["token"], "Registration should return token")
assert.NotNil(t, registerResp["user"], "Registration should return user")
// Verify login with same credentials
loginBody := map[string]string{
"username": "e2e_testuser",
"password": "SecurePass123!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code, "Login should succeed")
var loginResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
assert.NotEmpty(t, token, "Login should return token")
// Verify authenticated access
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
require.Equal(t, http.StatusOK, w.Code, "Should access protected route with valid token")
var meResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &meResp)
assert.Equal(t, "e2e_testuser", meResp["username"])
assert.Equal(t, "e2e@example.com", meResp["email"])
t.Log("✓ Authentication flow verified")
// ============ Phase 2: Create 5 Residences ============
t.Log("Phase 2: Creating 5 residences")
residenceNames := []string{
"Main House",
"Beach House",
"Mountain Cabin",
"City Apartment",
"Lake House",
}
residenceIDs := make([]uint, 5)
for i, name := range residenceNames {
createBody := map[string]interface{}{
"name": name,
"street_address": fmt.Sprintf("%d Test St", (i+1)*100),
"city": "Austin",
"state_province": "TX",
"postal_code": fmt.Sprintf("787%02d", i),
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences", createBody, token)
require.Equal(t, http.StatusCreated, w.Code, "Should create residence: %s", name)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
residenceIDs[i] = uint(data["id"].(float64))
assert.Equal(t, name, data["name"])
}
t.Logf("✓ Created 5 residences with IDs: %v", residenceIDs)
// ============ Phase 3: Create 20 Tasks with Various Statuses ============
t.Log("Phase 3: Creating 20 tasks with various statuses and due dates")
// Use a fixed reference date for consistent testing
// This ensures tasks fall into predictable kanban columns
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
// Task configurations: title, residenceIndex, daysFromNow, status
taskConfigs := []struct {
title string
residenceIndex int
daysFromNow int
status string // "active", "in_progress", "completed", "cancelled", "archived"
}{
// Overdue tasks (due before today)
{"Overdue Task 1 - Fix roof", 0, -5, "active"},
{"Overdue Task 2 - Repair fence", 1, -3, "active"},
{"Overdue Task 3 - Paint garage", 2, -1, "in_progress"}, // In progress but overdue
// Due soon tasks (today to 30 days)
{"Due Today - Check smoke detectors", 0, 0, "active"},
{"Due Tomorrow - Water plants", 1, 1, "active"},
{"Due in 5 days - Clean gutters", 2, 5, "active"},
{"Due in 10 days - Service HVAC", 3, 10, "active"},
{"Due in 20 days - Pressure wash deck", 4, 20, "in_progress"},
// Upcoming tasks (beyond 30 days or no due date)
{"Due in 35 days - Annual inspection", 0, 35, "active"},
{"Due in 45 days - Refinish floors", 1, 45, "active"},
{"No due date - Organize garage", 2, -999, "active"}, // -999 = no due date
// Completed tasks
{"Completed Task 1 - Replace filters", 0, -10, "completed"},
{"Completed Task 2 - Fix doorbell", 1, -7, "completed"},
{"Completed Task 3 - Clean windows", 2, 5, "completed"}, // Due soon but completed
// Cancelled tasks
{"Cancelled Task 1 - Build shed", 3, 15, "cancelled"},
{"Cancelled Task 2 - Install pool", 4, 60, "cancelled"},
// Archived tasks (hidden from kanban board)
{"Archived Task 1 - Old project", 0, -30, "archived"},
{"Archived Task 2 - Deprecated work", 1, -20, "archived"},
// Additional active tasks for variety
{"Regular Task - Mow lawn", 3, 3, "active"},
{"Regular Task - Trim hedges", 4, 7, "active"},
}
type createdTask struct {
ID uint
Title string
ResidenceID uint
DueDate *time.Time
Status string
ExpectedColumn string
}
createdTasks := make([]createdTask, 0, len(taskConfigs))
for _, cfg := range taskConfigs {
residenceID := residenceIDs[cfg.residenceIndex]
taskBody := map[string]interface{}{
"residence_id": residenceID,
"title": cfg.title,
"description": fmt.Sprintf("E2E test task - %s", cfg.status),
}
// Set due date unless -999 (no due date)
var dueDate *time.Time
if cfg.daysFromNow != -999 {
d := startOfToday.AddDate(0, 0, cfg.daysFromNow)
dueDate = &d
taskBody["due_date"] = d.Format(time.RFC3339)
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token)
require.Equal(t, http.StatusCreated, w.Code, "Should create task: %s", cfg.title)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
taskID := uint(data["id"].(float64))
// Apply status changes
switch cfg.status {
case "in_progress":
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
case "completed":
// Create a completion
completionBody := map[string]interface{}{
"task_id": taskID,
"notes": "Completed for E2E test",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
require.Equal(t, http.StatusCreated, w.Code)
case "cancelled":
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/cancel", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
case "archived":
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/archive", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
}
// Determine expected kanban column
expectedColumn := determineExpectedColumn(cfg.daysFromNow, cfg.status, 30)
createdTasks = append(createdTasks, createdTask{
ID: taskID,
Title: cfg.title,
ResidenceID: residenceID,
DueDate: dueDate,
Status: cfg.status,
ExpectedColumn: expectedColumn,
})
}
t.Logf("✓ Created %d tasks with various statuses", len(createdTasks))
// ============ Phase 4: Verify Residences Return Correctly ============
t.Log("Phase 4: Verifying residences return correctly")
// List all residences
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var residenceList []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceList)
assert.Len(t, residenceList, 5, "Should have 5 residences")
// Verify each residence individually
for i, expectedName := range residenceNames {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", residenceIDs[i]), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, expectedName, resp["name"], "Residence name should match")
}
t.Log("✓ All 5 residences verified")
// ============ Phase 5: Verify Tasks Return Correctly ============
t.Log("Phase 5: Verifying tasks return correctly")
// List all tasks
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var taskListResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskListResp)
// Count total visible tasks across all columns
totalTasks := 0
if columns, ok := taskListResp["columns"].([]interface{}); ok {
for _, col := range columns {
column := col.(map[string]interface{})
if tasks, ok := column["tasks"].([]interface{}); ok {
totalTasks += len(tasks)
}
}
}
expectedVisibleTasks := 0
for _, task := range createdTasks {
if task.ExpectedColumn != "" {
expectedVisibleTasks++
}
}
assert.Equal(t, expectedVisibleTasks, totalTasks, "Should have %d visible tasks", expectedVisibleTasks)
// Verify individual task retrieval
for _, task := range createdTasks {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", task.ID), nil, token)
require.Equal(t, http.StatusOK, w.Code, "Should retrieve task: %s", task.Title)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, task.Title, resp["title"], "Task title should match")
}
t.Log("✓ All 20 tasks verified")
// ============ Phase 6: Kanban Verification 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
offset string // for documentation
}{
{"UTC", "UTC", "UTC+0"},
{"Tokyo", "Asia/Tokyo", "UTC+9"},
{"Auckland", "Pacific/Auckland", "UTC+13"},
{"NewYork", "America/New_York", "UTC-5"},
{"Honolulu", "Pacific/Honolulu", "UTC-10"},
}
for _, tz := range timezones {
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)
nowInTZ := time.Now().In(loc)
// 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{}
json.Unmarshal(w.Body.Bytes(), &kanbanResp)
// Verify kanban structure
columns, ok := kanbanResp["columns"].([]interface{})
require.True(t, ok, "Should have columns array")
// Expected column names
expectedColumns := map[string]bool{
"overdue_tasks": false,
"in_progress_tasks": false,
"due_soon_tasks": false,
"upcoming_tasks": false,
"completed_tasks": false,
}
for _, col := range columns {
column := col.(map[string]interface{})
colName := column["name"].(string)
expectedColumns[colName] = true
// Verify column has required fields
assert.NotEmpty(t, column["display_name"], "Column should have display_name")
assert.NotNil(t, column["tasks"], "Column should have tasks array")
assert.NotNil(t, column["count"], "Column should have count")
// Verify count matches tasks length
tasks := column["tasks"].([]interface{})
count := int(column["count"].(float64))
assert.Equal(t, len(tasks), count, "Count should match tasks length for %s", colName)
}
// Verify all expected columns exist
for colName, found := range expectedColumns {
assert.True(t, found, "Should have column: %s", colName)
}
t.Logf(" ✓ Kanban valid with X-Timezone=%s (local: %s)", tz.location, nowInTZ.Format("2006-01-02 15:04"))
}
t.Log("✓ Kanban endpoint correctly processes X-Timezone header")
// ============ Phase 7: Verify 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)
require.Equal(t, http.StatusOK, w.Code)
var fullKanban map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &fullKanban)
// Log distribution for debugging
columnCounts := getColumnCounts(fullKanban)
t.Log(" Task distribution:")
for colName, count := range columnCounts {
t.Logf(" %s: %d tasks", colName, count)
}
// 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 each task is in expected column (or hidden for cancelled/archived)
t.Log(" Verifying each task's column membership by ID:")
for _, task := range createdTasks {
if task.ExpectedColumn == "" {
found := false
for colName, ids := range columnTaskIDs {
for _, id := range ids {
if id == task.ID {
found = true
assert.Fail(t, "Hidden task unexpectedly visible",
"Task ID %d ('%s') should be hidden from kanban but appeared in '%s'",
task.ID, task.Title, colName)
break
}
}
}
assert.False(t, found, "Task ID %d ('%s') should be hidden from kanban", task.ID, task.Title)
if !found {
t.Logf(" ✓ Task %d ('%s') correctly hidden from board", task.ID, task.Title)
}
continue
}
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 total equals expected visible tasks (sanity check)
total := 0
for _, ids := range columnTaskIDs {
total += len(ids)
}
assert.Equal(t, expectedVisibleTasks, total, "Total tasks across all columns should be %d", expectedVisibleTasks)
t.Logf("✓ All %d visible tasks verified in correct columns by ID", expectedVisibleTasks)
// ============ Phase 9: Create User B ============
t.Log("Phase 9: Creating User B and verifying login")
// Register User B
registerBodyB := map[string]string{
"username": "e2e_userb",
"email": "e2e_userb@example.com",
"password": "SecurePass456!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBodyB, "")
require.Equal(t, http.StatusCreated, w.Code, "User B registration should succeed")
// Login as User B
loginBodyB := map[string]string{
"username": "e2e_userb",
"password": "SecurePass456!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBodyB, "")
require.Equal(t, http.StatusOK, w.Code, "User B login should succeed")
var loginRespB map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &loginRespB)
tokenB := loginRespB["token"].(string)
assert.NotEmpty(t, tokenB, "User B should have a token")
// Verify User B can access their own profile
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var meBResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &meBResp)
assert.Equal(t, "e2e_userb", meBResp["username"])
t.Log("✓ User B created and verified")
// ============ Phase 10: User A Shares Residence with User B ============
t.Log("Phase 10: User A shares residence with User B")
// We'll share residenceIDs[0] (Main House) with User B
sharedResidenceID := residenceIDs[0]
sharedResidenceName := residenceNames[0]
// User B cannot access the residence initially
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", sharedResidenceID), nil, tokenB)
assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT have access before sharing")
// User A generates share code for the residence
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code", sharedResidenceID), nil, token)
require.Equal(t, http.StatusOK, w.Code, "User A should be able to generate share code")
var shareCodeResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &shareCodeResp)
shareCodeObj := shareCodeResp["share_code"].(map[string]interface{})
shareCode := shareCodeObj["code"].(string)
assert.Len(t, shareCode, 6, "Share code should be 6 characters")
// User B joins with the share code
joinBody := map[string]interface{}{
"code": shareCode,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", joinBody, tokenB)
require.Equal(t, http.StatusOK, w.Code, "User B should be able to join with share code")
t.Logf("✓ User A shared '%s' (ID: %d) with User B using code: %s", sharedResidenceName, sharedResidenceID, shareCode)
// ============ Phase 11: Verify User B Has Access to Shared Residence Only ============
t.Log("Phase 11: Verifying User B has access to shared residence only")
// User B should now be able to access the shared residence
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", sharedResidenceID), nil, tokenB)
require.Equal(t, http.StatusOK, w.Code, "User B should have access to shared residence")
var sharedResidenceResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &sharedResidenceResp)
assert.Equal(t, sharedResidenceName, sharedResidenceResp["name"], "Shared residence name should match")
// User B should only see 1 residence in their list
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBResidences []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBResidences)
assert.Len(t, userBResidences, 1, "User B should only see 1 residence")
assert.Equal(t, sharedResidenceName, userBResidences[0]["name"], "User B's only residence should be the shared one")
// User B should NOT have access to other residences
for i, resID := range residenceIDs {
if resID != sharedResidenceID {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", resID), nil, tokenB)
assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT have access to residence %d (%s)", resID, residenceNames[i])
}
}
t.Log("✓ User B has access to shared residence only")
// ============ Phase 12: Verify User B Sees Tasks for Shared Residence ============
t.Log("Phase 12: Verifying User B sees tasks for shared residence")
// Get tasks for the shared residence as User B
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", sharedResidenceID), nil, tokenB)
require.Equal(t, http.StatusOK, w.Code, "User B should be able to get tasks for shared residence")
var userBKanbanResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBKanbanResp)
// Count tasks in User B's kanban for the shared residence
userBTaskCount := 0
userBColumns := userBKanbanResp["columns"].([]interface{})
for _, col := range userBColumns {
column := col.(map[string]interface{})
tasks := column["tasks"].([]interface{})
userBTaskCount += len(tasks)
}
// Count expected tasks for shared residence (residenceIndex=0 in our config)
expectedTasksForResidence := 0
for _, task := range createdTasks {
if task.ResidenceID == sharedResidenceID && task.ExpectedColumn != "" {
expectedTasksForResidence++
}
}
assert.Equal(t, expectedTasksForResidence, userBTaskCount,
"User B should see %d tasks for shared residence, got %d", expectedTasksForResidence, userBTaskCount)
// User B should NOT be able to get tasks for other residences
for _, resID := range residenceIDs {
if resID != sharedResidenceID {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", resID), nil, tokenB)
assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT access tasks for unshared residence %d", resID)
}
}
// User B's full task list should only show tasks from shared residence
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBFullKanban map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBFullKanban)
totalUserBTasks := 0
fullColumns := userBFullKanban["columns"].([]interface{})
for _, col := range fullColumns {
column := col.(map[string]interface{})
tasks := column["tasks"].([]interface{})
totalUserBTasks += len(tasks)
}
assert.Equal(t, expectedTasksForResidence, totalUserBTasks,
"User B's full task list should only contain %d tasks from shared residence", expectedTasksForResidence)
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 with X-Timezone header")
// Test that User B's kanban endpoint accepts X-Timezone header
for _, tz := range timezones {
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 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{}
json.Unmarshal(w.Body.Bytes(), &tzKanbanResp)
// Verify kanban structure
tzColumns := tzKanbanResp["columns"].([]interface{})
require.NotEmpty(t, tzColumns, "Should have columns")
// Verify all expected columns exist
foundColumns := make(map[string]bool)
for _, col := range tzColumns {
column := col.(map[string]interface{})
colName := column["name"].(string)
foundColumns[colName] = true
// Verify column has required fields
assert.NotEmpty(t, column["display_name"])
assert.NotNil(t, column["tasks"])
assert.NotNil(t, column["count"])
}
expectedColumnNames := []string{
"overdue_tasks", "in_progress_tasks", "due_soon_tasks",
"upcoming_tasks", "completed_tasks",
}
for _, colName := range expectedColumnNames {
assert.True(t, foundColumns[colName], "User B should have column: %s", colName)
}
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 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")
// User A creates 5 contractors, one for each residence
contractorNames := []string{
"Main House Plumber",
"Beach House Electrician",
"Mountain Cabin Roofer",
"City Apartment HVAC",
"Lake House Landscaper",
}
contractorIDs := make([]uint, 5)
for i, name := range contractorNames {
residenceID := residenceIDs[i]
contractorBody := map[string]interface{}{
"residence_id": residenceID,
"name": name,
"company": fmt.Sprintf("%s Inc.", name),
"phone": fmt.Sprintf("555-000-%04d", i+1),
"email": fmt.Sprintf("contractor%d@example.com", i+1),
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, token)
require.Equal(t, http.StatusCreated, w.Code, "User A should create contractor: %s", name)
// Contractor API returns the object directly without "data" wrapper
var contractorResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &contractorResp)
contractorIDs[i] = uint(contractorResp["id"].(float64))
t.Logf(" Created contractor '%s' for residence '%s'", name, residenceNames[i])
}
t.Logf("✓ User A created 5 contractors with IDs: %v", contractorIDs)
// Verify User A can see all 5 contractors
w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var userAContractors []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userAContractors)
assert.Len(t, userAContractors, 5, "User A should see all 5 contractors")
// User B should only see contractors for the shared residence
w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBContractors []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBContractors)
assert.Len(t, userBContractors, 1, "User B should see only 1 contractor (from shared residence)")
// Verify User B's contractor is from the shared residence
if len(userBContractors) > 0 {
userBContractor := userBContractors[0]
assert.Equal(t, contractorNames[0], userBContractor["name"], "User B's contractor should be '%s'", contractorNames[0])
}
// Verify User B can access the shared residence's contractor directly
sharedContractorID := contractorIDs[0]
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/contractors/%d", sharedContractorID), nil, tokenB)
require.Equal(t, http.StatusOK, w.Code, "User B should access contractor for shared residence")
// Verify User B cannot access contractors for other residences
for i, contractorID := range contractorIDs {
if i != 0 { // Skip the shared residence's contractor
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/contractors/%d", contractorID), nil, tokenB)
assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT access contractor %d (%s)", contractorID, contractorNames[i])
}
}
// Verify User B can list contractors by shared residence
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/contractors/by-residence/%d", sharedResidenceID), nil, tokenB)
require.Equal(t, http.StatusOK, w.Code, "User B should list contractors for shared residence")
var userBResidenceContractors []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBResidenceContractors)
assert.Len(t, userBResidenceContractors, 1, "User B should see 1 contractor for shared residence")
// Verify User B cannot list contractors for other residences
for i, resID := range residenceIDs {
if resID != sharedResidenceID {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/contractors/by-residence/%d", resID), nil, tokenB)
assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT list contractors for residence %d (%s)", resID, residenceNames[i])
}
}
t.Log("✓ User B contractor access verified correctly")
// ============ Phase 15: Final Summary ============
t.Log("\n========== E2E Test Summary ==========")
t.Log("✓ User A registration and login")
t.Log("✓ 5 residences created and verified")
t.Log("✓ 20 tasks created with various statuses")
t.Log("✓ Tasks correctly distributed in kanban columns")
t.Log("✓ Kanban structure verified across 5 timezones (User A)")
t.Log("✓ User B registration and login")
t.Log("✓ Residence sharing from User A to User B")
t.Log("✓ User B access limited to shared residence only")
t.Log("✓ User B sees only tasks for shared residence")
t.Log("✓ User B kanban verified across 5 timezones")
t.Log("✓ 5 contractors created (one per residence)")
t.Log("✓ User B access limited to contractor for shared residence")
t.Log("========================================")
}
// determineExpectedColumn determines which kanban column a task should be in
// based on its due date offset, status, and threshold
func determineExpectedColumn(daysFromNow int, status string, threshold int) string {
// This must match the categorization chain priority order:
// Cancelled and archived tasks are intentionally hidden from kanban board view.
// Remaining visible columns follow:
// 1. Completed
// 2. InProgress (takes precedence over date-based columns)
// 3. Overdue
// 4. DueSoon
// 5. Upcoming
switch status {
case "cancelled", "archived":
return "" // Hidden from board
case "completed":
return "completed_tasks"
case "in_progress":
// In-progress ALWAYS goes to in_progress column because it has
// higher priority (4) than overdue (5) in the categorization chain
return "in_progress_tasks"
default: // "active"
if daysFromNow == -999 {
return "upcoming_tasks" // No due date
}
if daysFromNow < 0 {
return "overdue_tasks"
}
if daysFromNow < threshold {
return "due_soon_tasks"
}
return "upcoming_tasks"
}
}
// ============ Helper Functions ============
func formatID(id float64) string {
return fmt.Sprintf("%d", uint(id))
}
// setupContractorTest sets up a test environment including contractor routes
func setupContractorTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
// Create repositories
userRepo := repositories.NewUserRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
// Create config
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
// Create services
authService := services.NewAuthService(userRepo, cfg)
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
taskService := services.NewTaskService(taskRepo, residenceRepo)
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
// Create handlers
authHandler := handlers.NewAuthHandler(authService, nil, nil)
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil)
taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware
e := echo.New()
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")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
residences.GET("", residenceHandler.ListResidences)
residences.POST("", residenceHandler.CreateResidence)
residences.GET("/:id", residenceHandler.GetResidence)
residences.PUT("/:id", residenceHandler.UpdateResidence)
residences.DELETE("/:id", residenceHandler.DeleteResidence)
residences.POST("/:id/generate-share-code", residenceHandler.GenerateShareCode)
residences.GET("/:id/users", residenceHandler.GetResidenceUsers)
residences.DELETE("/:id/users/:user_id", residenceHandler.RemoveResidenceUser)
}
api.POST("/residences/join-with-code", residenceHandler.JoinWithCode)
tasks := api.Group("/tasks")
{
tasks.GET("", taskHandler.ListTasks)
tasks.POST("", taskHandler.CreateTask)
tasks.GET("/:id", taskHandler.GetTask)
tasks.PUT("/:id", taskHandler.UpdateTask)
tasks.DELETE("/:id", taskHandler.DeleteTask)
}
contractors := api.Group("/contractors")
{
contractors.GET("", contractorHandler.ListContractors)
contractors.POST("", contractorHandler.CreateContractor)
contractors.GET("/:id", contractorHandler.GetContractor)
contractors.PUT("/:id", contractorHandler.UpdateContractor)
contractors.DELETE("/:id", contractorHandler.DeleteContractor)
}
}
return &TestApp{
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
}
// ============ Test 1: Recurring Task Lifecycle ============
// TestIntegration_RecurringTaskLifecycle tests the complete lifecycle of recurring tasks:
// - Create tasks with different frequencies (once, weekly, monthly)
// - Complete each task multiple times
// - Verify NextDueDate advances correctly
// - Verify task moves between kanban columns appropriately
func TestIntegration_RecurringTaskLifecycle(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "recurring_user", "recurring@test.com", "password123")
// Create residence
residenceBody := map[string]interface{}{"name": "Recurring Task 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))
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
t.Log("Phase 1: Creating tasks with different frequencies")
// Frequency IDs from seeded data:
// 1 = Once (nil days)
// 2 = Weekly (7 days)
// 3 = Monthly (30 days)
frequencyOnce := uint(1)
frequencyWeekly := uint(2)
frequencyMonthly := uint(3)
// Create one-time task (due today)
oneTimeTaskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "One-Time Task",
"due_date": startOfToday.Format(time.RFC3339),
"frequency_id": frequencyOnce,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", oneTimeTaskBody, token)
require.Equal(t, http.StatusCreated, w.Code)
var oneTimeResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &oneTimeResp)
oneTimeData := oneTimeResp["data"].(map[string]interface{})
oneTimeTaskID := uint(oneTimeData["id"].(float64))
t.Logf(" Created one-time task (ID: %d)", oneTimeTaskID)
// Create weekly recurring task (due today)
weeklyTaskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "Weekly Recurring Task",
"due_date": startOfToday.Format(time.RFC3339),
"frequency_id": frequencyWeekly,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", weeklyTaskBody, token)
require.Equal(t, http.StatusCreated, w.Code)
var weeklyResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &weeklyResp)
weeklyData := weeklyResp["data"].(map[string]interface{})
weeklyTaskID := uint(weeklyData["id"].(float64))
t.Logf(" Created weekly task (ID: %d)", weeklyTaskID)
// Create monthly recurring task (due today)
monthlyTaskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "Monthly Recurring Task",
"due_date": startOfToday.Format(time.RFC3339),
"frequency_id": frequencyMonthly,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", monthlyTaskBody, token)
require.Equal(t, http.StatusCreated, w.Code)
var monthlyResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &monthlyResp)
monthlyData := monthlyResp["data"].(map[string]interface{})
monthlyTaskID := uint(monthlyData["id"].(float64))
t.Logf(" Created monthly task (ID: %d)", monthlyTaskID)
t.Log("✓ All tasks created")
// Phase 2: Complete one-time task
t.Log("Phase 2: Complete one-time task and verify it's marked completed")
completionBody := map[string]interface{}{
"task_id": oneTimeTaskID,
"notes": "Completed one-time task",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
require.Equal(t, http.StatusCreated, w.Code)
// Verify task is in completed column
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", oneTimeTaskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var oneTimeAfterComplete map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &oneTimeAfterComplete)
// One-time task should have next_due_date = nil after completion
assert.Nil(t, oneTimeAfterComplete["next_due_date"], "One-time task should have nil next_due_date after completion")
assert.Equal(t, "completed_tasks", oneTimeAfterComplete["kanban_column"], "One-time task should be in completed column")
t.Log("✓ One-time task completed and in completed column")
// Phase 3: Complete weekly task multiple times
t.Log("Phase 3: Complete weekly task and verify NextDueDate advances by 7 days")
completionBody = map[string]interface{}{
"task_id": weeklyTaskID,
"notes": "First weekly completion",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
require.Equal(t, http.StatusCreated, w.Code)
// Verify weekly task NextDueDate advanced
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", weeklyTaskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var weeklyAfterFirst map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &weeklyAfterFirst)
// Weekly task should have next_due_date ~7 days from now
assert.NotNil(t, weeklyAfterFirst["next_due_date"], "Weekly task should have next_due_date after completion")
nextDueDateStr := weeklyAfterFirst["next_due_date"].(string)
nextDueDate, err := time.Parse(time.RFC3339, nextDueDateStr)
require.NoError(t, err)
// Should be approximately 7 days from today (could be +/- based on completion time)
daysDiff := int(nextDueDate.Sub(startOfToday).Hours() / 24)
assert.GreaterOrEqual(t, daysDiff, 6, "Next due date should be at least 6 days away")
assert.LessOrEqual(t, daysDiff, 8, "Next due date should be at most 8 days away")
// Weekly task should be in upcoming or due_soon column (not completed)
kanbanColumn := weeklyAfterFirst["kanban_column"].(string)
assert.NotEqual(t, "completed_tasks", kanbanColumn, "Weekly recurring task should NOT be in completed after first completion")
t.Logf("✓ Weekly task NextDueDate advanced to %s (kanban: %s)", nextDueDateStr, kanbanColumn)
// Phase 4: Complete monthly task
t.Log("Phase 4: Complete monthly task and verify NextDueDate advances by 30 days")
completionBody = map[string]interface{}{
"task_id": monthlyTaskID,
"notes": "Monthly completion",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
require.Equal(t, http.StatusCreated, w.Code)
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", monthlyTaskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var monthlyAfterComplete map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &monthlyAfterComplete)
assert.NotNil(t, monthlyAfterComplete["next_due_date"], "Monthly task should have next_due_date after completion")
monthlyNextDueStr := monthlyAfterComplete["next_due_date"].(string)
monthlyNextDue, err := time.Parse(time.RFC3339, monthlyNextDueStr)
require.NoError(t, err)
monthlyDaysDiff := int(monthlyNextDue.Sub(startOfToday).Hours() / 24)
assert.GreaterOrEqual(t, monthlyDaysDiff, 29, "Monthly next due date should be at least 29 days away")
assert.LessOrEqual(t, monthlyDaysDiff, 31, "Monthly next due date should be at most 31 days away")
t.Logf("✓ Monthly task NextDueDate advanced to %s", monthlyNextDueStr)
// Phase 5: Verify kanban distribution
t.Log("Phase 5: Verify kanban column distribution")
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var kanbanResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &kanbanResp)
columns := kanbanResp["columns"].([]interface{})
columnTasks := make(map[string]int)
for _, col := range columns {
column := col.(map[string]interface{})
colName := column["name"].(string)
tasks := column["tasks"].([]interface{})
columnTasks[colName] = len(tasks)
}
// One-time task should be completed
assert.Equal(t, 1, columnTasks["completed_tasks"], "Should have 1 completed task (one-time)")
// Weekly task (due in 7 days) is within 30-day threshold, so it's in due_soon
// Monthly task (due in ~30 days) is at/beyond threshold, so it's in upcoming
assert.Equal(t, 1, columnTasks["due_soon_tasks"], "Should have 1 due_soon task (weekly - 7 days)")
assert.Equal(t, 1, columnTasks["upcoming_tasks"], "Should have 1 upcoming task (monthly - 30 days)")
t.Log("✓ Kanban distribution verified")
t.Log("\n========== Recurring Task Lifecycle Test Complete ==========")
}
// ============ Test 2: Multi-User Complex Sharing ============
// TestIntegration_MultiUserSharing tests complex sharing scenarios with multiple users:
// - 3 users with various residence sharing combinations
// - Verify each user sees only their accessible residences/tasks
// - Test user removal from shared residences
func TestIntegration_MultiUserSharing(t *testing.T) {
app := setupIntegrationTest(t)
t.Log("Phase 1: Create 3 users")
tokenA := app.registerAndLogin(t, "user_a", "usera@test.com", "password123")
tokenB := app.registerAndLogin(t, "user_b", "userb@test.com", "password123")
tokenC := app.registerAndLogin(t, "user_c", "userc@test.com", "password123")
t.Log("✓ Created users A, B, and C")
// Phase 2: User A creates 3 residences
t.Log("Phase 2: User A creates 3 residences")
residenceNames := []string{"Residence 1 (shared with B)", "Residence 2 (shared with C)", "Residence 3 (shared with B and C)"}
residenceIDs := make([]uint, 3)
for i, name := range residenceNames {
createBody := map[string]interface{}{"name": name}
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", createBody, tokenA)
require.Equal(t, http.StatusCreated, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
residenceIDs[i] = uint(data["id"].(float64))
}
t.Logf("✓ Created residences with IDs: %v", residenceIDs)
// Phase 3: Create tasks in each residence
t.Log("Phase 3: Create tasks in each residence")
for i, resID := range residenceIDs {
taskBody := map[string]interface{}{
"residence_id": resID,
"title": fmt.Sprintf("Task for Residence %d", i+1),
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, tokenA)
require.Equal(t, http.StatusCreated, w.Code)
}
t.Log("✓ Created tasks in all residences")
// Phase 4: Share residence 1 with B only
t.Log("Phase 4: Share residence 1 with User B")
w := app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code", residenceIDs[0]), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
var shareResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &shareResp)
shareCode1 := shareResp["share_code"].(map[string]interface{})["code"].(string)
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", map[string]interface{}{"code": shareCode1}, tokenB)
require.Equal(t, http.StatusOK, w.Code)
t.Log("✓ Residence 1 shared with User B")
// Phase 5: Share residence 2 with C only
t.Log("Phase 5: Share residence 2 with User C")
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code", residenceIDs[1]), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &shareResp)
shareCode2 := shareResp["share_code"].(map[string]interface{})["code"].(string)
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", map[string]interface{}{"code": shareCode2}, tokenC)
require.Equal(t, http.StatusOK, w.Code)
t.Log("✓ Residence 2 shared with User C")
// Phase 6: Share residence 3 with both B and C
t.Log("Phase 6: Share residence 3 with both Users B and C")
// Share with B
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code", residenceIDs[2]), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &shareResp)
shareCode3B := shareResp["share_code"].(map[string]interface{})["code"].(string)
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", map[string]interface{}{"code": shareCode3B}, tokenB)
require.Equal(t, http.StatusOK, w.Code)
// Share with C
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code", residenceIDs[2]), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &shareResp)
shareCode3C := shareResp["share_code"].(map[string]interface{})["code"].(string)
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", map[string]interface{}{"code": shareCode3C}, tokenC)
require.Equal(t, http.StatusOK, w.Code)
t.Log("✓ Residence 3 shared with both Users B and C")
// Phase 7: Verify each user sees correct residences
t.Log("Phase 7: Verify residence visibility for each user")
// User A sees all 3
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
var userAResidences []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userAResidences)
assert.Len(t, userAResidences, 3, "User A should see 3 residences")
// User B sees residence 1 and 3
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBResidences []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBResidences)
assert.Len(t, userBResidences, 2, "User B should see 2 residences (1 and 3)")
// User C sees residence 2 and 3
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, tokenC)
require.Equal(t, http.StatusOK, w.Code)
var userCResidences []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userCResidences)
assert.Len(t, userCResidences, 2, "User C should see 2 residences (2 and 3)")
t.Log("✓ All users see correct residences")
// Phase 8: Verify task visibility
t.Log("Phase 8: Verify task visibility for each user")
// User B should see 2 tasks (from residence 1 and 3)
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBTasks map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBTasks)
userBTaskCount := countTasksInKanban(userBTasks)
assert.Equal(t, 2, userBTaskCount, "User B should see 2 tasks")
// User C should see 2 tasks (from residence 2 and 3)
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks", nil, tokenC)
require.Equal(t, http.StatusOK, w.Code)
var userCTasks map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userCTasks)
userCTaskCount := countTasksInKanban(userCTasks)
assert.Equal(t, 2, userCTaskCount, "User C should see 2 tasks")
t.Log("✓ All users see correct tasks")
// Phase 9: Remove User B from residence 3
t.Log("Phase 9: Remove User B from residence 3")
// Get User B's ID
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBInfo map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBInfo)
userBID := uint(userBInfo["id"].(float64))
// Remove User B from residence 3
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d", residenceIDs[2], userBID), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
t.Log("✓ User B removed from residence 3")
// Phase 10: Verify User B lost access to residence 3
t.Log("Phase 10: Verify User B lost access to residence 3, C still has access")
// User B should now only see residence 1
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &userBResidences)
assert.Len(t, userBResidences, 1, "User B should now see only 1 residence")
// User B cannot access residence 3
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", residenceIDs[2]), nil, tokenB)
assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT have access to residence 3")
// User C still has access to residence 3
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", residenceIDs[2]), nil, tokenC)
assert.Equal(t, http.StatusOK, w.Code, "User C should still have access to residence 3")
t.Log("✓ User B lost access, User C retained access")
// Phase 11: Verify User B only sees 1 task now
t.Log("Phase 11: Verify User B now sees only 1 task")
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &userBTasks)
userBTaskCount = countTasksInKanban(userBTasks)
assert.Equal(t, 1, userBTaskCount, "User B should now see only 1 task")
t.Log("✓ User B task count updated correctly")
t.Log("\n========== Multi-User Sharing Test Complete ==========")
}
// ============ Test 3: Task State Transitions ============
// TestIntegration_TaskStateTransitions tests all valid task state transitions:
// - Create → in_progress → complete → archive → unarchive
// - Create → cancel → uncancel
// - Verify kanban column changes with each transition
func TestIntegration_TaskStateTransitions(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "state_user", "state@test.com", "password123")
// Create residence
residenceBody := map[string]interface{}{"name": "State Transition 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))
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
t.Log("Phase 1: Create task (should be in due_soon)")
taskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "State Transition Task",
"due_date": startOfToday.AddDate(0, 0, 5).Format(time.RFC3339), // Due in 5 days
}
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))
assert.Equal(t, "due_soon_tasks", taskData["kanban_column"], "New task should be in due_soon")
t.Logf("✓ Task created (ID: %d) in due_soon_tasks", taskID)
// Phase 2: Mark in progress
t.Log("Phase 2: Mark task as in_progress")
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var inProgressResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &inProgressResp)
inProgressData := inProgressResp["data"].(map[string]interface{})
assert.True(t, inProgressData["in_progress"].(bool), "Task should be marked as in_progress")
assert.Equal(t, "in_progress_tasks", inProgressData["kanban_column"], "Task should be in in_progress column")
t.Log("✓ Task marked in_progress")
// Phase 3: Complete the task
t.Log("Phase 3: Complete the task")
completionBody := map[string]interface{}{
"task_id": taskID,
"notes": "Completed for state transition test",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
require.Equal(t, http.StatusCreated, w.Code)
// Get task to verify state
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var completedResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &completedResp)
assert.Equal(t, "completed_tasks", completedResp["kanban_column"], "Completed task should be in completed column")
assert.False(t, completedResp["in_progress"].(bool), "Completed task should not be in_progress")
t.Log("✓ Task completed and in completed column")
// Phase 4: Archive the task
t.Log("Phase 4: Archive the task")
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/archive", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var archivedResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &archivedResp)
archivedData := archivedResp["data"].(map[string]interface{})
assert.True(t, archivedData["is_archived"].(bool), "Task should be archived")
// Archived tasks should go to cancelled_tasks column (archived = hidden from main view)
assert.Equal(t, "cancelled_tasks", archivedData["kanban_column"], "Archived task should be in cancelled column")
t.Log("✓ Task archived")
// Phase 5: Unarchive the task
t.Log("Phase 5: Unarchive the task")
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/unarchive", taskID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var unarchivedResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &unarchivedResp)
unarchivedData := unarchivedResp["data"].(map[string]interface{})
assert.False(t, unarchivedData["is_archived"].(bool), "Task should not be archived")
// After unarchive, it should return to completed (since it was completed before archiving)
assert.Equal(t, "completed_tasks", unarchivedData["kanban_column"], "Unarchived completed task should return to completed")
t.Log("✓ Task unarchived")
// Phase 6: Test cancel flow with a new task
t.Log("Phase 6: Create new task and test cancel/uncancel")
taskBody2 := map[string]interface{}{
"residence_id": residenceID,
"title": "Cancel Test Task",
"due_date": startOfToday.AddDate(0, 0, 10).Format(time.RFC3339),
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody2, token)
require.Equal(t, http.StatusCreated, w.Code)
var taskResp2 map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskResp2)
taskData2 := taskResp2["data"].(map[string]interface{})
taskID2 := uint(taskData2["id"].(float64))
// Cancel the task
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/cancel", taskID2), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var cancelledResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &cancelledResp)
cancelledData := cancelledResp["data"].(map[string]interface{})
assert.True(t, cancelledData["is_cancelled"].(bool), "Task should be cancelled")
assert.Equal(t, "cancelled_tasks", cancelledData["kanban_column"], "Cancelled task should be in cancelled column")
t.Log("✓ Task cancelled")
// Uncancel the task
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/uncancel", taskID2), nil, token)
require.Equal(t, http.StatusOK, w.Code)
var uncancelledResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &uncancelledResp)
uncancelledData := uncancelledResp["data"].(map[string]interface{})
assert.False(t, uncancelledData["is_cancelled"].(bool), "Task should not be cancelled")
assert.Equal(t, "due_soon_tasks", uncancelledData["kanban_column"], "Uncancelled task should return to due_soon")
t.Log("✓ Task uncancelled")
// Phase 7: Test trying to cancel already cancelled task (should fail)
t.Log("Phase 7: Verify cannot cancel already cancelled task")
// First cancel it
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/cancel", taskID2), nil, token)
require.Equal(t, http.StatusOK, w.Code)
// Try to cancel again
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/tasks/%d/cancel", taskID2), nil, token)
assert.Equal(t, http.StatusBadRequest, w.Code, "Should not be able to cancel already cancelled task")
t.Log("✓ Correctly prevented double cancellation")
// Phase 8: Delete a task
t.Log("Phase 8: Delete a task")
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/tasks/%d", taskID2), nil, token)
require.Equal(t, http.StatusOK, w.Code)
// Verify task is deleted
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", taskID2), nil, token)
assert.Equal(t, http.StatusNotFound, w.Code, "Deleted task should not be found")
t.Log("✓ Task deleted")
t.Log("\n========== Task State Transitions Test Complete ==========")
}
// ============ Test 4: Date Boundary Edge Cases ============
// TestIntegration_DateBoundaryEdgeCases tests edge cases around date boundaries:
// - 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")
// Create residence
residenceBody := map[string]interface{}{"name": "Boundary 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))
// 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{}{
"residence_id": residenceID,
"title": "Due Today Task",
"due_date": startOfToday.Format(time.RFC3339),
}
w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskToday, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
var todayResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &todayResp)
todayData := todayResp["data"].(map[string]interface{})
assert.Equal(t, "due_soon_tasks", todayData["kanban_column"], "Task due today should be in due_soon (not overdue)")
t.Log("✓ Task due today correctly in due_soon")
// Phase 2: Task due yesterday (should be overdue)
t.Log("Phase 2: Task due yesterday (should be overdue)")
taskYesterday := map[string]interface{}{
"residence_id": residenceID,
"title": "Due Yesterday Task",
"due_date": startOfToday.AddDate(0, 0, -1).Format(time.RFC3339),
}
w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskYesterday, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
var yesterdayResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &yesterdayResp)
yesterdayData := yesterdayResp["data"].(map[string]interface{})
assert.Equal(t, "overdue_tasks", yesterdayData["kanban_column"], "Task due yesterday should be overdue")
t.Log("✓ Task due yesterday correctly in overdue")
// Phase 3: Task due at threshold-1 (day 29, should be due_soon)
t.Log("Phase 3: Task due at threshold-1 (day 29, should be due_soon)")
taskDay29 := map[string]interface{}{
"residence_id": residenceID,
"title": "Due in 29 Days Task",
"due_date": startOfToday.AddDate(0, 0, threshold-1).Format(time.RFC3339),
}
w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskDay29, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
var day29Resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &day29Resp)
day29Data := day29Resp["data"].(map[string]interface{})
assert.Equal(t, "due_soon_tasks", day29Data["kanban_column"], "Task due in 29 days should be due_soon")
t.Log("✓ Task at threshold-1 correctly in due_soon")
// Phase 4: Task due exactly at threshold (day 30, should be upcoming - exclusive boundary)
t.Log("Phase 4: Task due exactly at threshold (day 30, should be upcoming)")
taskDay30 := map[string]interface{}{
"residence_id": residenceID,
"title": "Due in 30 Days Task",
"due_date": startOfToday.AddDate(0, 0, threshold).Format(time.RFC3339),
}
w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskDay30, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
var day30Resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &day30Resp)
day30Data := day30Resp["data"].(map[string]interface{})
assert.Equal(t, "upcoming_tasks", day30Data["kanban_column"], "Task due exactly at threshold should be upcoming")
t.Log("✓ Task at threshold correctly in upcoming")
// Phase 5: Task due beyond threshold (day 31, should be upcoming)
t.Log("Phase 5: Task due beyond threshold (day 31, should be upcoming)")
taskDay31 := map[string]interface{}{
"residence_id": residenceID,
"title": "Due in 31 Days Task",
"due_date": startOfToday.AddDate(0, 0, threshold+1).Format(time.RFC3339),
}
w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskDay31, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
var day31Resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &day31Resp)
day31Data := day31Resp["data"].(map[string]interface{})
assert.Equal(t, "upcoming_tasks", day31Data["kanban_column"], "Task due in 31 days should be upcoming")
t.Log("✓ Task beyond threshold correctly in upcoming")
// Phase 6: Task with no due date (should be upcoming)
t.Log("Phase 6: Task with no due date (should be upcoming)")
taskNoDue := map[string]interface{}{
"residence_id": residenceID,
"title": "No Due Date Task",
}
w = app.makeAuthenticatedRequestWithTimezone(t, "POST", "/api/tasks", taskNoDue, token, testTimezone)
require.Equal(t, http.StatusCreated, w.Code)
var noDueResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &noDueResp)
noDueData := noDueResp["data"].(map[string]interface{})
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 (using same timezone)
t.Log("Phase 7: Verify final kanban distribution")
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{}
json.Unmarshal(w.Body.Bytes(), &kanbanResp)
columnCounts := getColumnCounts(kanbanResp)
assert.Equal(t, 1, columnCounts["overdue_tasks"], "Should have 1 overdue task (yesterday)")
assert.Equal(t, 2, columnCounts["due_soon_tasks"], "Should have 2 due_soon tasks (today, day 29)")
assert.Equal(t, 3, columnCounts["upcoming_tasks"], "Should have 3 upcoming tasks (day 30, day 31, no due)")
t.Log("✓ Final kanban distribution verified")
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:
// - Create residence with tasks, completions, and contractors
// - Delete residence
// - Verify cascading effects
func TestIntegration_CascadeOperations(t *testing.T) {
app := setupIntegrationTest(t)
token := app.registerAndLogin(t, "cascade_user", "cascade@test.com", "password123")
t.Log("Phase 1: Create residence")
residenceBody := map[string]interface{}{"name": "Cascade 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))
t.Logf("✓ Created residence (ID: %d)", residenceID)
// Phase 2: Create tasks
t.Log("Phase 2: Create tasks")
taskIDs := make([]uint, 3)
for i := 0; i < 3; i++ {
taskBody := map[string]interface{}{
"residence_id": residenceID,
"title": fmt.Sprintf("Cascade Task %d", i+1),
}
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{})
taskIDs[i] = uint(taskData["id"].(float64))
}
t.Logf("✓ Created 3 tasks with IDs: %v", taskIDs)
// Phase 3: Create completions
t.Log("Phase 3: Create task completions")
completionIDs := make([]uint, 2)
for i := 0; i < 2; i++ {
completionBody := map[string]interface{}{
"task_id": taskIDs[i],
"notes": fmt.Sprintf("Completion for task %d", i+1),
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token)
require.Equal(t, http.StatusCreated, w.Code)
var completionResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &completionResp)
completionData := completionResp["data"].(map[string]interface{})
completionIDs[i] = uint(completionData["id"].(float64))
}
t.Logf("✓ Created 2 completions with IDs: %v", completionIDs)
// Phase 4: Create contractor
t.Log("Phase 4: Create contractor")
contractorBody := map[string]interface{}{
"residence_id": residenceID,
"name": "Cascade Contractor",
"phone": "555-1234",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, token)
require.Equal(t, http.StatusCreated, w.Code)
var contractorResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &contractorResp)
contractorID := uint(contractorResp["id"].(float64))
t.Logf("✓ Created contractor (ID: %d)", contractorID)
// Phase 5: Verify all resources exist
t.Log("Phase 5: Verify all resources exist before deletion")
// Tasks exist
for _, taskID := range taskIDs {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", taskID), nil, token)
assert.Equal(t, http.StatusOK, w.Code, "Task %d should exist", taskID)
}
// Completions exist
for _, completionID := range completionIDs {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/completions/%d", completionID), nil, token)
assert.Equal(t, http.StatusOK, w.Code, "Completion %d should exist", completionID)
}
// Contractor exists
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/contractors/%d", contractorID), nil, token)
assert.Equal(t, http.StatusOK, w.Code, "Contractor should exist")
t.Log("✓ All resources verified to exist")
// Phase 6: Delete the residence
t.Log("Phase 6: Delete the residence")
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d", residenceID), nil, token)
require.Equal(t, http.StatusOK, w.Code)
t.Log("✓ Residence deleted")
// Phase 7: Verify cascade effects - residence no longer accessible
t.Log("Phase 7: Verify cascade effects")
// Residence should not be accessible
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/residences/%d", residenceID), nil, token)
assert.Equal(t, http.StatusForbidden, w.Code, "Deleted residence should return forbidden")
// Tasks should not be accessible (depends on cascade behavior)
for _, taskID := range taskIDs {
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", taskID), nil, token)
// Either 404 (deleted) or 403 (residence access denied)
assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusForbidden,
"Task %d should not be accessible after residence deletion", taskID)
}
// Contractor - behavior depends on implementation
// May still exist but not be associated with residence, or may be deleted
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/contractors/%d", contractorID), nil, token)
// Could be 404, 403, or 200 depending on contractor cascade behavior
t.Logf(" Contractor access after residence deletion: %d", w.Code)
t.Log("✓ Cascade effects verified")
t.Log("\n========== Cascade Operations Test Complete ==========")
}
// ============ Test 6: Multi-User Operations ============
// TestIntegration_MultiUserOperations tests two users with shared access making operations:
// - Two users can both create tasks in shared residence
// - Both can update and complete tasks
// Note: Truly concurrent operations are not tested due to SQLite limitations in test environment
func TestIntegration_MultiUserOperations(t *testing.T) {
app := setupIntegrationTest(t)
t.Log("Phase 1: Setup users and shared residence")
tokenA := app.registerAndLogin(t, "multiuser_a", "multiusera@test.com", "password123")
tokenB := app.registerAndLogin(t, "multiuser_b", "multiuserb@test.com", "password123")
// User A creates residence
residenceBody := map[string]interface{}{"name": "Multi-User Test House"}
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, tokenA)
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))
// Share with User B
w = app.makeAuthenticatedRequest(t, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code", residenceID), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
var shareResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &shareResp)
shareCode := shareResp["share_code"].(map[string]interface{})["code"].(string)
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", map[string]interface{}{"code": shareCode}, tokenB)
require.Equal(t, http.StatusOK, w.Code)
t.Logf("✓ Shared residence (ID: %d) between users A and B", residenceID)
// Phase 2: Both users create tasks (sequentially to avoid SQLite issues)
t.Log("Phase 2: Both users create tasks")
// User A creates a task
taskBodyA := map[string]interface{}{
"residence_id": residenceID,
"title": "Task from User A",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBodyA, tokenA)
require.Equal(t, http.StatusCreated, w.Code)
var taskRespA map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskRespA)
taskDataA := taskRespA["data"].(map[string]interface{})
taskIDA := uint(taskDataA["id"].(float64))
// User B creates a task
taskBodyB := map[string]interface{}{
"residence_id": residenceID,
"title": "Task from User B",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBodyB, tokenB)
require.Equal(t, http.StatusCreated, w.Code)
var taskRespB map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskRespB)
taskDataB := taskRespB["data"].(map[string]interface{})
taskIDB := uint(taskDataB["id"].(float64))
t.Logf("✓ Both users created tasks (A: %d, B: %d)", taskIDA, taskIDB)
// Phase 3: Verify both tasks exist
t.Log("Phase 3: Verify both tasks exist")
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", residenceID), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
var kanbanResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &kanbanResp)
totalTasks := countTasksInKanban(kanbanResp)
assert.Equal(t, 2, totalTasks, "Should have 2 tasks")
t.Log("✓ Both tasks created successfully")
// Phase 4: User B can update User A's task
t.Log("Phase 4: User B updates User A's task")
updateBody := map[string]interface{}{
"title": "Updated by User B",
}
w = app.makeAuthenticatedRequest(t, "PUT", fmt.Sprintf("/api/tasks/%d", taskIDA), updateBody, tokenB)
require.Equal(t, http.StatusOK, w.Code)
t.Log("✓ User B successfully updated User A's task")
// Phase 5: Verify update
t.Log("Phase 5: Verify task was updated")
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/%d", taskIDA), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
var finalTaskResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &finalTaskResp)
assert.Equal(t, "Updated by User B", finalTaskResp["title"], "Task should have User B's title")
t.Log("✓ Update verified")
// Phase 6: Both users complete the same task
t.Log("Phase 6: Both users add completions to the same task")
// User A completes
completionBodyA := map[string]interface{}{
"task_id": taskIDB,
"notes": "Completed by User A",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBodyA, tokenA)
require.Equal(t, http.StatusCreated, w.Code)
// User B completes
completionBodyB := map[string]interface{}{
"task_id": taskIDB,
"notes": "Completed by User B",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBodyB, tokenB)
require.Equal(t, http.StatusCreated, w.Code)
t.Log("✓ Both users added completions")
// Phase 7: Verify task has 2 completions
t.Log("Phase 7: Verify task has 2 completions")
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/completions?task_id=%d", taskIDB), nil, tokenA)
require.Equal(t, http.StatusOK, w.Code)
var completionsResp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &completionsResp)
assert.Len(t, completionsResp, 2, "Task should have 2 completions")
t.Log("✓ Multi-user completions verified")
t.Log("\n========== Multi-User Operations Test Complete ==========")
}
// ============ Helper Functions for New Tests ============
// countTasksInKanban counts total tasks across all kanban columns
func countTasksInKanban(kanbanResp map[string]interface{}) int {
total := 0
if columns, ok := kanbanResp["columns"].([]interface{}); ok {
for _, col := range columns {
column := col.(map[string]interface{})
if tasks, ok := column["tasks"].([]interface{}); ok {
total += len(tasks)
}
}
}
return total
}
// getColumnCounts returns a map of column name to task count
func getColumnCounts(kanbanResp map[string]interface{}) map[string]int {
counts := make(map[string]int)
if columns, ok := kanbanResp["columns"].([]interface{}); ok {
for _, col := range columns {
column := col.(map[string]interface{})
colName := column["name"].(string)
if tasks, ok := column["tasks"].([]interface{}); ok {
counts[colName] = len(tasks)
}
}
}
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
}