Files
honeyDueAPI/internal/integration/integration_test.go
Trey t 6dac34e373 Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:52:08 -06:00

2611 lines
97 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"
)
// 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
// 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")
// 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
for i := 1; i <= 3; i++ {
taskBody := map[string]interface{}{
"residence_id": residenceID,
"title": "Task " + formatID(float64(i)),
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token)
require.Equal(t, http.StatusCreated, w.Code)
}
// Get tasks by residence (kanban view)
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/by-residence/"+formatID(float64(residenceID)), nil, token)
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 (should appear in cancelled column)
{"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 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)
}
}
}
assert.Equal(t, 20, totalTasks, "Should have 20 total tasks")
// 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 categorization across 5 timezones")
// Test timezones spanning the extremes
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 timezone: %s (%s)", tz.name, tz.offset)
loc, err := time.LoadLocation(tz.location)
require.NoError(t, err, "Should load timezone: %s", tz.location)
// Get current time in this timezone
nowInTZ := time.Now().In(loc)
// Query kanban for first residence with timezone parameter
// Note: The API should accept timezone info via query param or header
// For now, we'll verify the kanban structure is correct
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", residenceIDs[0]), nil, token)
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,
"cancelled_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 structure verified at %s (%s)", nowInTZ.Format("2006-01-02 15:04"), tz.name)
}
t.Log("✓ Kanban verification complete across all 5 timezones")
// ============ Phase 7: Verify Task Distribution in Kanban Columns ============
t.Log("Phase 7: Verifying task distribution in kanban columns")
// 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)
columns := fullKanban["columns"].([]interface{})
columnCounts := make(map[string]int)
columnTasks := make(map[string][]string)
for _, col := range columns {
column := col.(map[string]interface{})
colName := column["name"].(string)
tasks := column["tasks"].([]interface{})
columnCounts[colName] = len(tasks)
for _, t := range tasks {
task := t.(map[string]interface{})
columnTasks[colName] = append(columnTasks[colName], task["title"].(string))
}
}
// Log distribution for debugging
t.Log(" Task distribution:")
for colName, count := range columnCounts {
t.Logf(" %s: %d tasks", colName, count)
}
// Verify expected distributions based on task configs
// Note: Exact counts depend on current date relative to due dates
// We verify that:
// 1. Cancelled + Archived tasks are in cancelled_tasks column
// 2. Completed tasks are in completed_tasks column
// 3. In-progress tasks without overdue go to in_progress_tasks (unless overdue)
// 4. Active tasks are distributed based on due dates
// Verify cancelled/archived tasks
cancelledCount := columnCounts["cancelled_tasks"]
assert.GreaterOrEqual(t, cancelledCount, 4, "Should have at least 4 cancelled/archived tasks")
// Verify completed tasks
completedCount := columnCounts["completed_tasks"]
assert.GreaterOrEqual(t, completedCount, 3, "Should have at least 3 completed tasks")
// Verify total equals 20
total := 0
for _, count := range columnCounts {
total += count
}
assert.Equal(t, 20, total, "Total tasks across all columns should be 20")
t.Log("✓ Task distribution verified")
// ============ 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 {
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 across different timezones")
// Test that User B sees consistent kanban structure across timezones
for _, tz := range timezones {
t.Logf(" Testing User B in timezone: %s (%s)", tz.name, tz.offset)
loc, err := time.LoadLocation(tz.location)
require.NoError(t, err)
nowInTZ := time.Now().In(loc)
// Get User B's kanban for shared residence
w = app.makeAuthenticatedRequest(t, "GET", fmt.Sprintf("/api/tasks/by-residence/%d", sharedResidenceID), nil, tokenB)
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", "cancelled_tasks",
}
for _, colName := range expectedColumnNames {
assert.True(t, foundColumns[colName], "User B should have column: %s", colName)
}
t.Logf(" ✓ User B kanban verified at %s (%s)", nowInTZ.Format("2006-01-02 15:04"), tz.name)
}
t.Log("✓ User B kanban verified across all timezones")
// ============ 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:
// 1. Cancelled (priority 1)
// 2. Archived (priority 2)
// 3. Completed (priority 3)
// 4. InProgress (priority 4) - takes precedence over date-based columns!
// 5. Overdue (priority 5)
// 6. DueSoon (priority 6)
// 7. Upcoming (priority 7)
switch status {
case "cancelled", "archived":
return "cancelled_tasks"
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
// 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 at 11:59 PM today (should be due_soon, not overdue)
// - Task due at threshold boundary (day 30)
// - Task due at day 31 (should be upcoming)
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))
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
threshold := 30
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.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskToday, token)
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.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskYesterday, token)
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.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskDay29, token)
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.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskDay30, token)
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.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskDay31, token)
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.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskNoDue, token)
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
t.Log("Phase 7: Verify final kanban 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)
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 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
}