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/honeydue-api/internal/dto/requests" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/repositories" "github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-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_BulkCreateTasks(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") tmpl := models.TaskTemplate{Title: "Change HVAC Filter", IsActive: true} require.NoError(t, db.Create(&tmpl).Error) authGroup := e.Group("/api/tasks") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/bulk/", handler.BulkCreateTasks) t.Run("creates all tasks and returns 201", func(t *testing.T) { req := requests.BulkCreateTasksRequest{ ResidenceID: residence.ID, Tasks: []requests.CreateTaskRequest{ {ResidenceID: residence.ID, Title: "Bulk A", TemplateID: &tmpl.ID}, {ResidenceID: residence.ID, Title: "Bulk B"}, }, } w := testutil.MakeRequest(e, "POST", "/api/tasks/bulk/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) var response map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) assert.EqualValues(t, 2, response["created_count"]) tasks := response["tasks"].([]interface{}) require.Len(t, tasks, 2) first := tasks[0].(map[string]interface{}) require.NotNil(t, first["template_id"]) assert.EqualValues(t, tmpl.ID, first["template_id"]) }) t.Run("empty task list returns 400", func(t *testing.T) { req := requests.BulkCreateTasksRequest{ ResidenceID: residence.ID, Tasks: []requests.CreateTaskRequest{}, } w := testutil.MakeRequest(e, "POST", "/api/tasks/bulk/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("more than 50 tasks rejected by validator", func(t *testing.T) { big := make([]requests.CreateTaskRequest, 51) for i := range big { big[i] = requests.CreateTaskRequest{ResidenceID: residence.ID, Title: "n"} } req := requests.BulkCreateTasksRequest{ResidenceID: residence.ID, Tasks: big} w := testutil.MakeRequest(e, "POST", "/api/tasks/bulk/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("foreign residence returns 403", func(t *testing.T) { foreigner := testutil.CreateTestUser(t, db, "intruder", "intruder@test.com", "password") foreignerResidence := testutil.CreateTestResidence(t, db, foreigner.ID, "Not Yours") req := requests.BulkCreateTasksRequest{ ResidenceID: foreignerResidence.ID, Tasks: []requests.CreateTaskRequest{ {ResidenceID: foreignerResidence.ID, Title: "Nope"}, }, } w := testutil.MakeRequest(e, "POST", "/api/tasks/bulk/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusForbidden) }) } 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"]) }) } // ============================================================================= // Part 3: Handler-Level Edge Cases (TDD) // ============================================================================= func TestTaskHandler_CreateCompletion_RatingValidation(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") authGroup := e.Group("/api/task-completions") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/", handler.CreateCompletion) tests := []struct { name string rating int wantStatus int }{ {"rating_0_rejected", 0, http.StatusBadRequest}, {"rating_negative1_rejected", -1, http.StatusBadRequest}, {"rating_1_accepted", 1, http.StatusCreated}, {"rating_5_accepted", 5, http.StatusCreated}, {"rating_6_rejected", 6, http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a fresh task for each accepted rating (otherwise it's completed) task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Rate Me "+tt.name) completedAt := time.Now().UTC() rating := tt.rating 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, tt.wantStatus) }) } } func TestTaskHandler_CreateTask_TitleBoundary(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("title_exactly_200_chars_accepted", func(t *testing.T) { title200 := "" for i := 0; i < 200; i++ { title200 += "A" } req := requests.CreateTaskRequest{ ResidenceID: residence.ID, Title: title200, } w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) }) t.Run("title_201_chars_rejected", func(t *testing.T) { title201 := "" for i := 0; i < 201; i++ { title201 += "A" } req := requests.CreateTaskRequest{ ResidenceID: residence.ID, Title: title201, } w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) } func TestTaskHandler_CreateTask_DescriptionBoundary(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("description_exactly_10000_chars_accepted", func(t *testing.T) { desc10000 := "" for i := 0; i < 10000; i++ { desc10000 += "B" } req := requests.CreateTaskRequest{ ResidenceID: residence.ID, Title: "Long Desc Task", Description: desc10000, } w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) }) } func TestTaskHandler_CreateTask_CustomIntervalDaysValidation(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("custom_interval_days_0_rejected", func(t *testing.T) { // Validation tag: min=1, so 0 should be rejected interval := 0 req := map[string]interface{}{ "residence_id": residence.ID, "title": "Custom Interval Zero", "custom_interval_days": interval, } w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("custom_interval_days_negative1_rejected", func(t *testing.T) { interval := -1 req := map[string]interface{}{ "residence_id": residence.ID, "title": "Custom Interval Negative", "custom_interval_days": interval, } w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("custom_interval_days_1_accepted", func(t *testing.T) { interval := 1 req := requests.CreateTaskRequest{ ResidenceID: residence.ID, Title: "Custom Interval One", CustomIntervalDays: &interval, } w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) }) }