Add PDF reports, file uploads, admin auth, and comprehensive tests
Features: - PDF service for generating task reports with ReportLab-style formatting - Storage service for file uploads (local and S3-compatible) - Admin authentication middleware with JWT support - Admin user model and repository Infrastructure: - Updated Docker configuration for admin panel builds - Email service enhancements for task notifications - Updated router with admin and file upload routes - Environment configuration updates Tests: - Unit tests for handlers (auth, residence, task) - Unit tests for models (user, residence, task) - Unit tests for repositories (user, residence, task) - Unit tests for services (residence, task) - Integration test setup - Test utilities for mocking database and services Database: - Admin user seed data - Updated test data seeds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
715
internal/integration/integration_test.go
Normal file
715
internal/integration/integration_test.go
Normal file
@@ -0,0 +1,715 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
"github.com/treytartt/mycrib-api/internal/handlers"
|
||||
"github.com/treytartt/mycrib-api/internal/middleware"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
"github.com/treytartt/mycrib-api/internal/services"
|
||||
"github.com/treytartt/mycrib-api/internal/testutil"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestApp holds all components for integration testing
|
||||
type TestApp struct {
|
||||
DB *gorm.DB
|
||||
Router *gin.Engine
|
||||
AuthHandler *handlers.AuthHandler
|
||||
ResidenceHandler *handlers.ResidenceHandler
|
||||
TaskHandler *handlers.TaskHandler
|
||||
UserRepo *repositories.UserRepository
|
||||
ResidenceRepo *repositories.ResidenceRepository
|
||||
TaskRepo *repositories.TaskRepository
|
||||
AuthService *services.AuthService
|
||||
}
|
||||
|
||||
func setupIntegrationTest(t *testing.T) *TestApp {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
|
||||
// Create repositories
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
taskRepo := repositories.NewTaskRepository(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)
|
||||
|
||||
// Create handlers
|
||||
authHandler := handlers.NewAuthHandler(authService, nil, nil)
|
||||
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil)
|
||||
taskHandler := handlers.NewTaskHandler(taskService, nil)
|
||||
|
||||
// Create router with real middleware
|
||||
router := gin.New()
|
||||
|
||||
// Public routes
|
||||
auth := router.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 := router.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/:userId", 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-statuses", taskHandler.GetStatuses)
|
||||
api.GET("/task-frequencies", taskHandler.GetFrequencies)
|
||||
}
|
||||
|
||||
return &TestApp{
|
||||
DB: db,
|
||||
Router: router,
|
||||
AuthHandler: authHandler,
|
||||
ResidenceHandler: residenceHandler,
|
||||
TaskHandler: taskHandler,
|
||||
UserRepo: userRepo,
|
||||
ResidenceRepo: residenceRepo,
|
||||
TaskRepo: taskRepo,
|
||||
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 400 (BadRequest)
|
||||
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.StatusBadRequest, w.Code)
|
||||
|
||||
// Try to register with same email - returns 400 (BadRequest)
|
||||
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.StatusBadRequest, 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)
|
||||
residenceID := createResp["id"].(float64)
|
||||
assert.NotZero(t, residenceID)
|
||||
assert.Equal(t, "My House", createResp["name"])
|
||||
assert.True(t, createResp["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)
|
||||
assert.Equal(t, "My Updated House", updateResp["name"])
|
||||
assert.Equal(t, "Dallas", updateResp["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)
|
||||
residenceID := createResp["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)
|
||||
residenceID := uint(residenceResp["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)
|
||||
taskID := taskResp["id"].(float64)
|
||||
assert.NotZero(t, taskID)
|
||||
assert.Equal(t, "Fix leaky faucet", taskResp["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 updateResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &updateResp)
|
||||
assert.Equal(t, "Fix kitchen faucet", updateResp["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)
|
||||
task := progressResp["task"].(map[string]interface{})
|
||||
status := task["status"].(map[string]interface{})
|
||||
assert.Equal(t, "In Progress", status["name"])
|
||||
|
||||
// 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)
|
||||
completionID := completionResp["id"].(float64)
|
||||
assert.NotZero(t, completionID)
|
||||
assert.Equal(t, "Fixed the faucet", completionResp["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)
|
||||
archivedTask := archiveResp["task"].(map[string]interface{})
|
||||
assert.True(t, archivedTask["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)
|
||||
cancelledTask := cancelResp["task"].(map[string]interface{})
|
||||
assert.True(t, cancelledTask["is_cancelled"].(bool))
|
||||
|
||||
// 10. Delete the task (returns 200 with message, not 204)
|
||||
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/tasks/"+formatID(taskID), nil, token)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestIntegration_TasksByResidenceKanban(t *testing.T) {
|
||||
app := setupIntegrationTest(t)
|
||||
token := app.registerAndLogin(t, "owner", "owner@test.com", "password123")
|
||||
|
||||
// Create residence
|
||||
residenceBody := map[string]interface{}{"name": "Kanban House"}
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var residenceResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
||||
residenceID := uint(residenceResp["id"].(float64))
|
||||
|
||||
// Create multiple tasks
|
||||
for i := 1; i <= 3; i++ {
|
||||
taskBody := map[string]interface{}{
|
||||
"residence_id": residenceID,
|
||||
"title": "Task " + formatID(float64(i)),
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// Get tasks by residence (kanban view)
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/by-residence/"+formatID(float64(residenceID)), nil, token)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var kanbanResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &kanbanResp)
|
||||
|
||||
columns := kanbanResp["columns"].([]interface{})
|
||||
assert.Greater(t, len(columns), 0)
|
||||
|
||||
// Check column structure
|
||||
for _, col := range columns {
|
||||
column := col.(map[string]interface{})
|
||||
assert.NotEmpty(t, column["name"])
|
||||
assert.NotEmpty(t, column["display_name"])
|
||||
assert.NotNil(t, column["tasks"])
|
||||
assert.NotNil(t, column["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Lookup Data Tests ============
|
||||
|
||||
func TestIntegration_LookupEndpoints(t *testing.T) {
|
||||
app := setupIntegrationTest(t)
|
||||
token := app.registerAndLogin(t, "user", "user@test.com", "password123")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
}{
|
||||
{"residence types", "/api/residence-types"},
|
||||
{"task categories", "/api/task-categories"},
|
||||
{"task priorities", "/api/task-priorities"},
|
||||
{"task statuses", "/api/task-statuses"},
|
||||
{"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)
|
||||
residenceID := residenceResp["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)
|
||||
taskID := taskResp["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)
|
||||
|
||||
// Verify all expected fields are present
|
||||
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 := resp[field]
|
||||
assert.True(t, exists, "Expected field %s to be present", field)
|
||||
}
|
||||
|
||||
// Check that nullable fields can be null
|
||||
assert.Nil(t, resp["bedrooms"])
|
||||
assert.Nil(t, resp["bathrooms"])
|
||||
}
|
||||
|
||||
// ============ Helper Functions ============
|
||||
|
||||
func formatID(id float64) string {
|
||||
return fmt.Sprintf("%d", uint(id))
|
||||
}
|
||||
Reference in New Issue
Block a user