Files
honeyDueAPI/internal/handlers/task_handler_test.go
Trey t 7690f07a2b Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:48:01 -06:00

843 lines
29 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/testutil"
"gorm.io/gorm"
)
func setupTaskHandler(t *testing.T) (*TaskHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskService := services.NewTaskService(taskRepo, residenceRepo)
handler := NewTaskHandler(taskService, nil)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestTaskHandler_CreateTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
t.Run("successful task creation", func(t *testing.T) {
req := requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Fix leaky faucet",
Description: "Kitchen faucet is dripping",
}
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, "Fix leaky faucet", taskData["title"])
assert.Equal(t, "Kitchen faucet is dripping", taskData["description"])
assert.Equal(t, float64(residence.ID), taskData["residence_id"])
assert.Equal(t, false, taskData["is_cancelled"])
assert.Equal(t, false, taskData["is_archived"])
})
t.Run("task creation with optional fields", func(t *testing.T) {
var category models.TaskCategory
db.First(&category)
var priority models.TaskPriority
db.First(&priority)
dueDate := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, 7)}
estimatedCost := decimal.NewFromFloat(150.50)
req := requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Install new lights",
Description: "Replace old light fixtures",
CategoryID: &category.ID,
PriorityID: &priority.ID,
DueDate: &dueDate,
EstimatedCost: &estimatedCost,
}
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, "Install new lights", taskData["title"])
// Note: Category and Priority are no longer preloaded for performance
// Client resolves from cache using category_id and priority_id
assert.NotNil(t, taskData["category_id"], "category_id should be set")
assert.NotNil(t, taskData["priority_id"], "priority_id should be set")
assert.Equal(t, "150.5", taskData["estimated_cost"]) // Decimal serializes as string
})
t.Run("task creation without residence access", func(t *testing.T) {
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
req := requests.CreateTaskRequest{
ResidenceID: otherResidence.ID,
Title: "Unauthorized Task",
}
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_GetTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetTask)
otherGroup := e.Group("/api/other-tasks")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.GET("/:id/", handler.GetTask)
t.Run("get own task", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Test Task", response["title"])
assert.Equal(t, float64(task.ID), response["id"])
})
t.Run("get non-existent task", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/9999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("access denied for other user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_ListTasks(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListTasks)
t.Run("list tasks", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
// ListTasks returns a kanban board object, not an array
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Verify kanban structure
assert.Contains(t, response, "columns")
assert.Contains(t, response, "days_threshold")
// Count total tasks across all columns
columns := response["columns"].([]interface{})
totalTasks := 0
for _, col := range columns {
column := col.(map[string]interface{})
totalTasks += int(column["count"].(float64))
}
assert.Equal(t, 3, totalTasks)
})
}
func TestTaskHandler_GetTasksByResidence(t *testing.T) {
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create tasks with different states
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Active Task")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/by-residence/:residence_id/", handler.GetTasksByResidence)
t.Run("get kanban columns", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "columns")
assert.Contains(t, response, "days_threshold")
assert.Contains(t, response, "residence_id")
columns := response["columns"].([]interface{})
assert.Len(t, columns, 5) // 5 visible kanban columns (cancelled/archived hidden)
})
t.Run("kanban column structure", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
columns := response["columns"].([]interface{})
firstColumn := columns[0].(map[string]interface{})
// Verify column structure
assert.Contains(t, firstColumn, "name")
assert.Contains(t, firstColumn, "display_name")
assert.Contains(t, firstColumn, "tasks")
assert.Contains(t, firstColumn, "count")
assert.Contains(t, firstColumn, "color")
assert.Contains(t, firstColumn, "icons")
assert.Contains(t, firstColumn, "button_types")
})
}
func TestTaskHandler_UpdateTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateTask)
t.Run("update task", func(t *testing.T) {
newTitle := "Updated Title"
newDesc := "Updated description"
req := requests.UpdateTaskRequest{
Title: &newTitle,
Description: &newDesc,
}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/tasks/%d/", task.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, "Updated Title", taskData["title"])
assert.Equal(t, "Updated description", taskData["description"])
})
}
func TestTaskHandler_DeleteTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Delete")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteTask)
t.Run("delete task", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.Contains(t, response["data"], "deleted")
})
}
func TestTaskHandler_CancelTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Cancel")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/cancel/", handler.CancelTask)
t.Run("cancel task", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, true, taskData["is_cancelled"])
})
t.Run("cancel already cancelled task", func(t *testing.T) {
// Already cancelled from previous test
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestTaskHandler_UncancelTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Uncancel")
// Cancel first
taskRepo := repositories.NewTaskRepository(db)
taskRepo.Cancel(task.ID, task.Version)
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/uncancel/", handler.UncancelTask)
t.Run("uncancel task", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, false, taskData["is_cancelled"])
})
}
func TestTaskHandler_ArchiveTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Archive")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/archive/", handler.ArchiveTask)
t.Run("archive task", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/archive/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, true, taskData["is_archived"])
})
}
func TestTaskHandler_UnarchiveTask(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Unarchive")
// Archive first
taskRepo := repositories.NewTaskRepository(db)
taskRepo.Archive(task.ID, task.Version)
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/unarchive/", handler.UnarchiveTask)
t.Run("unarchive task", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, false, taskData["is_archived"])
})
}
func TestTaskHandler_MarkInProgress(t *testing.T) {
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Start")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/mark-in-progress/", handler.MarkInProgress)
t.Run("mark in progress", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.NotNil(t, response["data"])
})
}
func TestTaskHandler_CreateCompletion(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Complete")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateCompletion)
t.Run("create completion", func(t *testing.T) {
completedAt := time.Now().UTC()
req := requests.CreateTaskCompletionRequest{
TaskID: task.ID,
CompletedAt: &completedAt,
Notes: "Completed successfully",
}
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
completionData := response["data"].(map[string]interface{})
testutil.AssertJSONFieldExists(t, completionData, "id")
assert.Equal(t, float64(task.ID), completionData["task_id"])
assert.Equal(t, "Completed successfully", completionData["notes"])
})
}
func TestTaskHandler_CreateCompletion_Rating6_Returns400(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Rate Me")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateCompletion)
t.Run("rating out of bounds rejected", func(t *testing.T) {
rating := 6
req := requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Rating: &rating,
}
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("rating zero rejected", func(t *testing.T) {
rating := 0
req := requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Rating: &rating,
}
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("rating 5 accepted", func(t *testing.T) {
rating := 5
completedAt := time.Now().UTC()
req := requests.CreateTaskCompletionRequest{
TaskID: task.ID,
CompletedAt: &completedAt,
Rating: &rating,
}
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
})
}
func TestTaskHandler_ListCompletions(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
// Create completions
for i := 0; i < 3; i++ {
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
})
}
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListCompletions)
t.Run("list completions", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/task-completions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response, 3)
})
}
func TestTaskHandler_GetCompletion(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Test completion",
}
db.Create(completion)
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetCompletion)
t.Run("get completion", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(completion.ID), response["id"])
assert.Equal(t, "Test completion", response["notes"])
})
}
func TestTaskHandler_DeleteCompletion(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
}
db.Create(completion)
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteCompletion)
t.Run("delete completion", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.Contains(t, response["data"], "deleted")
})
}
func TestTaskHandler_CreateTask_EmptyTitle_Returns400(t *testing.T) {
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
t.Run("empty body returns 400 with validation errors", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/", map[string]interface{}{}, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Should contain structured validation error
assert.Contains(t, response, "error")
assert.Contains(t, response, "fields")
fields := response["fields"].(map[string]interface{})
assert.Contains(t, fields, "residence_id", "validation error should reference 'residence_id'")
assert.Contains(t, fields, "title", "validation error should reference 'title'")
})
t.Run("missing title returns 400", func(t *testing.T) {
req := map[string]interface{}{
"residence_id": residence.ID,
}
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "fields")
fields := response["fields"].(map[string]interface{})
assert.Contains(t, fields, "title", "validation error should reference 'title'")
})
t.Run("missing residence_id returns 400", func(t *testing.T) {
req := map[string]interface{}{
"title": "Test Task",
}
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "fields")
fields := response["fields"].(map[string]interface{})
assert.Contains(t, fields, "residence_id", "validation error should reference 'residence_id'")
})
}
func TestTaskHandler_GetLookups(t *testing.T) {
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/categories/", handler.GetCategories)
authGroup.GET("/priorities/", handler.GetPriorities)
authGroup.GET("/frequencies/", handler.GetFrequencies)
t.Run("get categories", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/categories/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Greater(t, len(response), 0)
assert.Contains(t, response[0], "id")
assert.Contains(t, response[0], "name")
})
t.Run("get priorities", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/priorities/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Greater(t, len(response), 0)
assert.Contains(t, response[0], "id")
assert.Contains(t, response[0], "name")
assert.Contains(t, response[0], "level")
})
t.Run("get frequencies", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/frequencies/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Greater(t, len(response), 0)
})
}
func TestTaskHandler_JSONResponses(t *testing.T) {
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
authGroup.GET("/", handler.ListTasks)
t.Run("task response has correct JSON structure", func(t *testing.T) {
req := requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "JSON Test Task",
Description: "Testing JSON structure",
}
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
// Required fields in task data
assert.Contains(t, taskData, "id")
assert.Contains(t, taskData, "residence_id")
assert.Contains(t, taskData, "created_by_id")
assert.Contains(t, taskData, "title")
assert.Contains(t, taskData, "description")
assert.Contains(t, taskData, "is_cancelled")
assert.Contains(t, taskData, "is_archived")
assert.Contains(t, taskData, "created_at")
assert.Contains(t, taskData, "updated_at")
// Type checks
assert.IsType(t, float64(0), taskData["id"])
assert.IsType(t, "", taskData["title"])
assert.IsType(t, false, taskData["is_cancelled"])
assert.IsType(t, false, taskData["is_archived"])
// Summary should have expected fields
summary := response["summary"].(map[string]interface{})
assert.Contains(t, summary, "total_residences")
assert.Contains(t, summary, "total_tasks")
assert.Contains(t, summary, "total_pending")
assert.Contains(t, summary, "total_overdue")
})
t.Run("list response returns kanban board", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
// ListTasks returns a kanban board object with columns
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be a kanban board object
assert.Contains(t, response, "columns")
assert.Contains(t, response, "days_threshold")
assert.IsType(t, []interface{}{}, response["columns"])
})
}