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