Files
honeyDueAPI/internal/handlers/handler_coverage_test.go
Trey t 237c6b84ee
Some checks failed
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Onboarding: template backlink, bulk-create endpoint, climate-region scoring
Clients that send users through a multi-task onboarding step no longer
loop N POST /api/tasks/ calls and no longer create "orphan" tasks with
no reference to the TaskTemplate they came from.

Task model
- New task_template_id column + GORM FK (migration 000016)
- CreateTaskRequest.template_id, TaskResponse.template_id
- task_service.CreateTask persists the backlink

Bulk endpoint
- POST /api/tasks/bulk/ — 1-50 tasks in a single transaction,
  returns every created row + TotalSummary. Single residence access
  check, per-entry residence_id is overridden with batch value
- task_handler.BulkCreateTasks + task_service.BulkCreateTasks using
  db.Transaction; task_repo.CreateTx + FindByIDTx helpers

Climate-region scoring
- templateConditions gains ClimateRegionID; suggestion_service scores
  residence.PostalCode -> ZipToState -> GetClimateRegionIDByState against
  the template's conditions JSON (no penalty on mismatch / unknown ZIP)
- regionMatchBonus 0.35, totalProfileFields 14 -> 15
- Standalone GET /api/tasks/templates/by-region/ removed; legacy
  task_tasktemplate_regions many-to-many dropped (migration 000017).
  Region affinity now lives entirely in the template's conditions JSON

Tests
- +11 cases across task_service_test, task_handler_test, suggestion_
  service_test: template_id persistence, bulk rollback + cap + auth,
  region match / mismatch / no-ZIP / unknown-ZIP / stacks-with-others

Docs
- docs/openapi.yaml: /tasks/bulk/ + BulkCreateTasks schemas, template_id
  on TaskResponse + CreateTaskRequest, /templates/by-region/ removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:23:57 -05:00

1853 lines
73 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/config"
"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"
)
// =============================================================================
// Suggestion Handler Tests (previously zero coverage)
// =============================================================================
func setupSuggestionHandler(t *testing.T) (*SuggestionHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
suggestionService := services.NewSuggestionService(db, residenceRepo)
handler := NewSuggestionHandler(suggestionService)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestSuggestionHandler_GetSuggestions(t *testing.T) {
handler, e, db := setupSuggestionHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/suggestions/", handler.GetSuggestions)
t.Run("successful suggestions with valid residence", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/suggestions/?residence_id=%d", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("missing residence_id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/suggestions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("invalid residence_id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/suggestions/?residence_id=abc", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("access denied for other user residence", func(t *testing.T) {
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/suggestions/?residence_id=%d", otherResidence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestSuggestionHandler_NoAuth_Returns401(t *testing.T) {
handler, _, _ := setupSuggestionHandler(t)
e := testutil.SetupTestRouter()
e.GET("/api/tasks/suggestions/", handler.GetSuggestions)
w := testutil.MakeRequest(e, "GET", "/api/tasks/suggestions/?residence_id=1", nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
}
// =============================================================================
// Task Handler - Additional Error Path Tests
// =============================================================================
func TestTaskHandler_GetTask_InvalidID(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetTask)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestTaskHandler_UpdateTask_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateTask)
t.Run("invalid id returns 400", func(t *testing.T) {
newTitle := "Updated"
req := requests.UpdateTaskRequest{Title: &newTitle}
w := testutil.MakeRequest(e, "PUT", "/api/tasks/invalid/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
newTitle := "Updated"
req := requests.UpdateTaskRequest{Title: &newTitle}
w := testutil.MakeRequest(e, "PUT", "/api/tasks/99999/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("access denied for other user task", func(t *testing.T) {
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
otherTask := testutil.CreateTestTask(t, db, otherResidence.ID, otherUser.ID, "Other Task")
newTitle := "Hacked"
req := requests.UpdateTaskRequest{Title: &newTitle}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/tasks/%d/", otherTask.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_DeleteTask_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteTask)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/tasks/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/tasks/99999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_QuickComplete(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Quick Complete Me")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/quick-complete/", handler.QuickComplete)
t.Run("successful quick complete", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/quick-complete/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/invalid/quick-complete/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/99999/quick-complete/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("access denied for other user task", func(t *testing.T) {
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
otherTask := testutil.CreateTestTask(t, db, otherResidence.ID, otherUser.ID, "Other Task")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/quick-complete/", otherTask.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_GetTaskCompletions(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Completions Task")
// Create a completion
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Done",
}
require.NoError(t, db.Create(completion).Error)
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/completions/", handler.GetTaskCompletions)
t.Run("successful get completions", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/%d/completions/", 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.Len(t, response, 1)
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/invalid/completions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/99999/completions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_GetCompletion_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetCompletion)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/task-completions/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/task-completions/99999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_UpdateCompletion(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Update Completion Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Original notes",
}
require.NoError(t, db.Create(completion).Error)
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateCompletion)
t.Run("successful update", func(t *testing.T) {
req := map[string]interface{}{
"notes": "Updated notes",
}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/task-completions/%d/", completion.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)
assert.Equal(t, "Updated notes", response["notes"])
})
t.Run("invalid id returns 400", func(t *testing.T) {
req := map[string]interface{}{"notes": "test"}
w := testutil.MakeRequest(e, "PUT", "/api/task-completions/invalid/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
req := map[string]interface{}{"notes": "test"}
w := testutil.MakeRequest(e, "PUT", "/api/task-completions/99999/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_DeleteCompletion_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteCompletion)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/task-completions/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/task-completions/99999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_GetTasksByResidence_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/by-residence/:residence_id/", handler.GetTasksByResidence)
t.Run("invalid residence id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/by-residence/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("access denied for other user residence", func(t *testing.T) {
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", otherResidence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("days param out of range returns 400", func(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/?days=5000", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestTaskHandler_ListTasks_DaysParam(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListTasks)
t.Run("custom days param", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/?days=60", 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(60), response["days_threshold"])
})
t.Run("days_threshold param (backward compat)", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/?days_threshold=45", 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(45), response["days_threshold"])
})
t.Run("days out of range returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/?days=0", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("days too large returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/?days=4000", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestTaskHandler_CancelTask_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/cancel/", handler.CancelTask)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/invalid/cancel/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/99999/cancel/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_UncancelTask_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/uncancel/", handler.UncancelTask)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/invalid/uncancel/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not cancelled task returns error", func(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Not Cancelled")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token")
// Service does not validate that the task is actually cancelled before uncancelling;
// it succeeds silently (sets is_cancelled=false on an already non-cancelled task)
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestTaskHandler_ArchiveTask_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/archive/", handler.ArchiveTask)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/invalid/archive/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/99999/archive/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_UnarchiveTask_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/unarchive/", handler.UnarchiveTask)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/invalid/unarchive/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not archived task returns error", func(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Not Archived")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token")
// Service does not validate that the task is actually archived before unarchiving;
// it succeeds silently (sets is_archived=false on an already non-archived task)
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestTaskHandler_MarkInProgress_ErrorPaths(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/mark-in-progress/", handler.MarkInProgress)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/invalid/mark-in-progress/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/tasks/99999/mark-in-progress/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestTaskHandler_CreateCompletion_NoTaskID(t *testing.T) {
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateCompletion)
t.Run("missing task_id returns 400", func(t *testing.T) {
req := map[string]interface{}{
"notes": "No task id",
}
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("non-existent task returns 404", func(t *testing.T) {
completedAt := time.Now().UTC()
req := requests.CreateTaskCompletionRequest{
TaskID: 99999,
CompletedAt: &completedAt,
}
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
// =============================================================================
// Auth Handler - Additional Coverage
// =============================================================================
func TestAuthHandler_AppleSignIn_NotConfigured(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/apple-sign-in/", handler.AppleSignIn)
t.Run("returns 500 when apple auth not configured", func(t *testing.T) {
req := map[string]interface{}{
"id_token": "fake-token",
"user_id": "fake-user-id",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/apple-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
})
t.Run("missing identity_token returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/apple-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_GoogleSignIn_NotConfigured(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/google-sign-in/", handler.GoogleSignIn)
t.Run("returns 500 when google auth not configured", func(t *testing.T) {
req := map[string]interface{}{
"id_token": "fake-token",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/google-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
})
t.Run("missing id_token returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/google-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// setupAuthHandlerWithDB is like setupAuthHandler but also returns the underlying *gorm.DB
// for tests that need to create records like ConfirmationCode directly.
func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
authService := services.NewAuthService(userRepo, cfg)
handler := NewAuthHandler(authService, nil, nil)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestAuthHandler_VerifyEmail(t *testing.T) {
handler, e, db := setupAuthHandlerWithDB(t)
user := testutil.CreateTestUser(t, db, "verifytest", "verify@test.com", "Password123")
// Create confirmation code
confirmCode := &models.ConfirmationCode{
UserID: user.ID,
Code: "123456",
ExpiresAt: time.Now().Add(24 * time.Hour),
IsUsed: false,
}
require.NoError(t, db.Create(confirmCode).Error)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/verify-email/", handler.VerifyEmail)
t.Run("successful verification", func(t *testing.T) {
req := requests.VerifyEmailRequest{
Code: "123456",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", 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)
assert.Equal(t, true, response["verified"])
})
t.Run("wrong code returns error", func(t *testing.T) {
req := requests.VerifyEmailRequest{
Code: "999999",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
// Code already used or wrong code
assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound,
"expected 400 or 404, got %d", w.Code)
})
t.Run("missing code returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ResendVerification(t *testing.T) {
handler, e, db := setupAuthHandlerWithDB(t)
user := testutil.CreateTestUser(t, db, "resendtest", "resend@test.com", "Password123")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/resend-verification/", handler.ResendVerification)
t.Run("successful resend", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/auth/resend-verification/", 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, "message")
})
}
func TestAuthHandler_RefreshToken(t *testing.T) {
handler, e, db := setupAuthHandlerWithDB(t)
user := testutil.CreateTestUser(t, db, "refreshtest", "refresh@test.com", "Password123")
// Create auth token and use its actual key in the middleware
authToken := testutil.CreateTestToken(t, db, user.ID)
authGroup := e.Group("/api/auth")
authGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("auth_user", user)
c.Set("auth_token", authToken.Key)
return next(c)
}
})
authGroup.POST("/refresh/", handler.RefreshToken)
t.Run("successful refresh", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/auth/refresh/", nil, authToken.Key)
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, "token")
})
}
func TestAuthHandler_VerifyResetCode(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/verify-reset-code/", handler.VerifyResetCode)
t.Run("invalid code returns error", func(t *testing.T) {
req := requests.VerifyResetCodeRequest{
Email: "nonexistent@test.com",
Code: "999999",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-reset-code/", req, "")
// Should not be 200 since no valid code exists
assert.NotEqual(t, http.StatusOK, w.Code)
})
t.Run("missing fields returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-reset-code/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ResetPassword(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/reset-password/", handler.ResetPassword)
t.Run("invalid reset token returns error", func(t *testing.T) {
req := requests.ResetPasswordRequest{
ResetToken: "invalid-token",
NewPassword: "NewPassword123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
assert.NotEqual(t, http.StatusOK, w.Code)
})
t.Run("missing fields returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("short password returns 400", func(t *testing.T) {
req := requests.ResetPasswordRequest{
ResetToken: "some-token",
NewPassword: "short",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ForgotPassword_MissingEmail(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
t.Run("missing email returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// =============================================================================
// Residence Handler - Additional Error Paths
// =============================================================================
func TestResidenceHandler_GenerateShareCode_ErrorPaths(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Share with user
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, otherUser.ID)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode)
otherGroup := e.Group("/api/other-residences")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/residences/invalid/generate-share-code/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("non-owner cannot generate share code", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/other-residences/%d/generate-share-code/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("non-existent residence returns error", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/residences/99999/generate-share-code/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_GetShareCode_ErrorPaths(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/share-code/", handler.GetShareCode)
otherGroup := e.Group("/api/other-residences")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.GET("/:id/share-code/", handler.GetShareCode)
t.Run("non-member cannot get share code", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-residences/%d/share-code/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("non-existent residence returns error", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/99999/share-code/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_JoinWithCode_ValidationErrors(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "Password123")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(newUser))
authGroup.POST("/join-with-code/", handler.JoinWithCode)
t.Run("empty code returns 400", func(t *testing.T) {
req := map[string]interface{}{
"code": "",
}
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("missing code returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidenceUsers_ErrorPaths(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
otherGroup := e.Group("/api/other-residences")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.GET("/:id/users/", handler.GetResidenceUsers)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/users/", handler.GetResidenceUsers)
t.Run("access denied for non-member", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-residences/%d/users/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/invalid/users/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_RemoveUser_ErrorPaths(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Remove Test")
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser)
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser)
t.Run("invalid residence id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/invalid/users/%d/", sharedUser.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("invalid user id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/invalid/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("non-owner cannot remove users", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/shared-residences/%d/users/%d/", residence.ID, user.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("remove non-existent user returns error", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/99999/", residence.ID), nil, "test-token")
// Service does not verify that the user was actually a member before removing;
// the repo delete affects 0 rows but does not return an error, so the handler returns 200
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestResidenceHandler_GenerateSharePackage_ErrorPaths(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-share-package/", handler.GenerateSharePackage)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/residences/invalid/generate-share-package/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("non-existent residence returns error", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/residences/99999/generate-share-package/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_GenerateTasksReport(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Report Test")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-tasks-report/", handler.GenerateTasksReport)
t.Run("successful report generation", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/residences/%d/generate-tasks-report/", 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, "report")
assert.Contains(t, response, "residence_name")
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/residences/invalid/generate-tasks-report/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("non-existent residence returns error", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/residences/99999/generate-tasks-report/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_GenerateTasksReport_Disabled(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
// Create handler with PDF reports DISABLED
handler := NewResidenceHandler(residenceService, nil, nil, false)
e := testutil.SetupTestRouter()
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Disabled Report Test")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-tasks-report/", handler.GenerateTasksReport)
t.Run("feature disabled returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/residences/%d/generate-tasks-report/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// =============================================================================
// Contractor Handler - Additional Error Paths
// =============================================================================
func TestContractorHandler_ListContractorsByResidence_AccessDenied(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/by-residence/:residence_id/", handler.ListContractorsByResidence)
t.Run("access denied for non-member residence", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/contractors/by-residence/%d/", otherResidence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestContractorHandler_GetContractor_AccessDenied(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
otherContractor := testutil.CreateTestContractor(t, db, otherResidence.ID, otherUser.ID, "Other Plumber")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetContractor)
t.Run("access denied for other user contractor", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/contractors/%d/", otherContractor.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestContractorHandler_UpdateContractor_AccessDenied(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
otherContractor := testutil.CreateTestContractor(t, db, otherResidence.ID, otherUser.ID, "Other Plumber")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateContractor)
t.Run("access denied for other user contractor", func(t *testing.T) {
newName := "Hacked"
req := requests.UpdateContractorRequest{Name: &newName}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/contractors/%d/", otherContractor.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestContractorHandler_DeleteContractor_AccessDenied(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
otherContractor := testutil.CreateTestContractor(t, db, otherResidence.ID, otherUser.ID, "Other Plumber")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteContractor)
t.Run("access denied for other user contractor", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/contractors/%d/", otherContractor.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestContractorHandler_ToggleFavorite_AccessDenied(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
otherContractor := testutil.CreateTestContractor(t, db, otherResidence.ID, otherUser.ID, "Other Plumber")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/toggle-favorite/", handler.ToggleFavorite)
t.Run("access denied for other user contractor", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/contractors/%d/toggle-favorite/", otherContractor.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestContractorHandler_GetContractorTasks_NotFound(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/tasks/", handler.GetContractorTasks)
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/99999/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
// =============================================================================
// Document Handler - Additional Error Paths
// =============================================================================
func TestDocumentHandler_UploadDocumentImage_InvalidIDs(t *testing.T) {
handler, e, db := setupDocumentHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/documents")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/images/", handler.UploadDocumentImage)
t.Run("invalid document id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/documents/invalid/images/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestDocumentHandler_DeleteDocumentImage_InvalidIDs(t *testing.T) {
handler, e, db := setupDocumentHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/documents")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/images/:imageId/", handler.DeleteDocumentImage)
t.Run("invalid document id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/documents/invalid/images/1/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("invalid image id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/documents/1/images/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestDocumentHandler_ListDocuments_TypeFilter(t *testing.T) {
handler, e, db := setupDocumentHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Warranty Doc")
require.NoError(t, db.Model(doc).Update("document_type", "warranty").Error)
authGroup := e.Group("/api/documents")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListDocuments)
t.Run("filter by document_type", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/documents/?document_type=warranty", 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, 1)
})
t.Run("filter by non-matching type returns empty", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/documents/?document_type=insurance", 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, 0)
})
t.Run("filter by expiring_soon valid", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/documents/?expiring_soon=30", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("negative expiring_soon out of range", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/documents/?expiring_soon=-1", nil, "test-token")
// -1 parses successfully via Atoi, then fails the <1 range check, returning 400
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestDocumentHandler_ActivateDeactivate_AccessDenied(t *testing.T) {
handler, e, db := setupDocumentHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
otherGroup := e.Group("/api/documents")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.POST("/:id/activate/", handler.ActivateDocument)
otherGroup.POST("/:id/deactivate/", handler.DeactivateDocument)
t.Run("activate access denied", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/documents/%d/activate/", doc.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("deactivate access denied", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/documents/%d/deactivate/", doc.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
// =============================================================================
// Notification Handler - Additional Coverage
// =============================================================================
func TestNotificationHandler_RegisterDevice_Android(t *testing.T) {
handler, e, db := setupNotificationHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/notifications")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/devices/", handler.RegisterDevice)
t.Run("successful android device registration", func(t *testing.T) {
req := map[string]interface{}{
"name": "Pixel 8",
"device_id": "android-device-123",
"registration_id": "android-reg-abc",
"platform": "android",
}
w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
})
t.Run("duplicate registration updates existing", func(t *testing.T) {
req := map[string]interface{}{
"name": "Pixel 8 Updated",
"device_id": "android-device-123",
"registration_id": "android-reg-abc",
"platform": "android",
}
w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/", req, "test-token")
// Should succeed (upsert behavior)
assert.True(t, w.Code == http.StatusCreated || w.Code == http.StatusOK,
"expected 201 or 200, got %d", w.Code)
})
}
func TestNotificationHandler_UnregisterDevice_Valid(t *testing.T) {
handler, e, db := setupNotificationHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/notifications")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/devices/", handler.RegisterDevice)
authGroup.POST("/devices/unregister/", handler.UnregisterDevice)
// First register a device
regReq := map[string]interface{}{
"name": "iPhone 15",
"device_id": "test-device-unreg",
"registration_id": "test-reg-unreg",
"platform": "ios",
}
testutil.MakeRequest(e, "POST", "/api/notifications/devices/", regReq, "test-token")
t.Run("successful unregister", func(t *testing.T) {
req := map[string]interface{}{
"registration_id": "test-reg-unreg",
"platform": "ios",
}
w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/unregister/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("unregister non-existent device", func(t *testing.T) {
req := map[string]interface{}{
"registration_id": "nonexistent-reg-id",
"platform": "ios",
}
w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/unregister/", req, "test-token")
// Should be 404 or succeed silently
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusNotFound,
"expected 200 or 404, got %d", w.Code)
})
}
func TestNotificationHandler_DeleteDevice_Valid(t *testing.T) {
handler, e, db := setupNotificationHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create a device directly
apnsDevice := &models.APNSDevice{
UserID: &user.ID,
Name: "Test iPhone",
DeviceID: "delete-device-id",
RegistrationID: "delete-reg-id",
Active: true,
}
require.NoError(t, db.Create(apnsDevice).Error)
authGroup := e.Group("/api/notifications")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/devices/:id/", handler.DeleteDevice)
t.Run("successful delete with platform", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/notifications/devices/%d/?platform=ios", apnsDevice.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("not found after delete", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/notifications/devices/%d/?platform=ios", apnsDevice.ID), nil, "test-token")
// Device is deactivated (active=false) rather than deleted, so the second call
// still finds the device and "deactivates" it again, returning 200
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
// =============================================================================
// User Handler Tests (previously zero functional tests)
// =============================================================================
func setupUserHandler(t *testing.T) (*UserHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
userService := services.NewUserService(userRepo)
handler := NewUserHandler(userService)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestUserHandler_ListUsers(t *testing.T) {
handler, e, db := setupUserHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
// Create residence and share it
residence := testutil.CreateTestResidence(t, db, user.ID, "Shared House")
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/users")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListUsers)
t.Run("list users in shared residences", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/users/", 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, "count")
assert.Contains(t, response, "results")
})
}
func TestUserHandler_GetUser(t *testing.T) {
handler, e, db := setupUserHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
outsideUser := testutil.CreateTestUser(t, db, "outside", "outside@test.com", "Password123")
// Create residence and share it
residence := testutil.CreateTestResidence(t, db, user.ID, "Shared House")
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/users")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetUser)
t.Run("get shared user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/users/%d/", sharedUser.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("access denied for non-shared user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/users/%d/", outsideUser.ID), nil, "test-token")
// Service returns "user not found" (404) rather than "forbidden" (403) for
// non-shared users — this avoids revealing that a user exists
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/users/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/users/99999/", nil, "test-token")
assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusForbidden,
"expected 404 or 403, got %d", w.Code)
})
}
func TestUserHandler_ListProfiles(t *testing.T) {
handler, e, db := setupUserHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/users")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/profiles/", handler.ListProfiles)
t.Run("successful list profiles", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/users/profiles/", 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, "count")
assert.Contains(t, response, "results")
})
}
// =============================================================================
// Subscription Handler Tests (previously zero functional tests)
// =============================================================================
func setupSubscriptionHandler(t *testing.T) (*SubscriptionHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
handler := NewSubscriptionHandler(subscriptionService, nil)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestSubscriptionHandler_GetSubscription(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.GetSubscription)
t.Run("get subscription for new user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/subscription/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestSubscriptionHandler_GetSubscriptionStatus(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/status/", handler.GetSubscriptionStatus)
t.Run("get subscription status", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/subscription/status/", 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, "tier")
})
}
func TestSubscriptionHandler_ProcessPurchase_ValidationErrors(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/purchase/", handler.ProcessPurchase)
t.Run("missing fields returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/subscription/purchase/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("invalid platform returns 400", func(t *testing.T) {
req := map[string]interface{}{
"platform": "windows",
}
w := testutil.MakeRequest(e, "POST", "/api/subscription/purchase/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("ios without receipt returns 400", func(t *testing.T) {
req := map[string]interface{}{
"platform": "ios",
}
w := testutil.MakeRequest(e, "POST", "/api/subscription/purchase/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("android without purchase token returns 400", func(t *testing.T) {
req := map[string]interface{}{
"platform": "android",
}
w := testutil.MakeRequest(e, "POST", "/api/subscription/purchase/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestSubscriptionHandler_RestoreSubscription_ValidationErrors(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/restore/", handler.RestoreSubscription)
t.Run("missing fields returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/subscription/restore/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("ios without receipt returns 400", func(t *testing.T) {
req := map[string]interface{}{
"platform": "ios",
}
w := testutil.MakeRequest(e, "POST", "/api/subscription/restore/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestSubscriptionHandler_GetPromotions(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/promotions/", handler.GetPromotions)
t.Run("get promotions for user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/subscription/promotions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestSubscriptionHandler_GetFeatureBenefits(t *testing.T) {
handler, e, _ := setupSubscriptionHandler(t)
e.GET("/api/subscription/features/", handler.GetFeatureBenefits)
t.Run("get feature benefits", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/subscription/features/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestSubscriptionHandler_GetAllUpgradeTriggers(t *testing.T) {
handler, e, _ := setupSubscriptionHandler(t)
e.GET("/api/subscription/upgrade-triggers/", handler.GetAllUpgradeTriggers)
t.Run("get all upgrade triggers", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/subscription/upgrade-triggers/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestSubscriptionHandler_GetUpgradeTrigger(t *testing.T) {
handler, e, _ := setupSubscriptionHandler(t)
e.GET("/api/subscription/upgrade-trigger/:key/", handler.GetUpgradeTrigger)
t.Run("non-existent trigger key returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/subscription/upgrade-trigger/nonexistent/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestSubscriptionHandler_CancelSubscription(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/cancel/", handler.CancelSubscription)
t.Run("cancel subscription for free user", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/subscription/cancel/", nil, "test-token")
// Free user cancelling might return 400 or success
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusBadRequest,
"expected 200 or 400, got %d", w.Code)
})
}
func TestSubscriptionHandler_CreateCheckoutSession_NotConfigured(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/checkout/", handler.CreateCheckoutSession)
t.Run("stripe not configured returns 400", func(t *testing.T) {
req := map[string]interface{}{
"price_id": "price_123",
"success_url": "https://example.com/success",
"cancel_url": "https://example.com/cancel",
}
w := testutil.MakeRequest(e, "POST", "/api/subscription/checkout/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestSubscriptionHandler_CreatePortalSession_NotConfigured(t *testing.T) {
handler, e, db := setupSubscriptionHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
authGroup := e.Group("/api/subscription")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/portal/", handler.CreatePortalSession)
t.Run("stripe not configured returns 400", func(t *testing.T) {
req := map[string]interface{}{
"return_url": "https://example.com/return",
}
w := testutil.MakeRequest(e, "POST", "/api/subscription/portal/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// =============================================================================
// Task Template Handler Tests (previously zero coverage)
// =============================================================================
func setupTaskTemplateHandler(t *testing.T) (*TaskTemplateHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
templateRepo := repositories.NewTaskTemplateRepository(db)
templateService := services.NewTaskTemplateService(templateRepo)
handler := NewTaskTemplateHandler(templateService)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestTaskTemplateHandler_GetTemplates(t *testing.T) {
handler, e, db := setupTaskTemplateHandler(t)
testutil.SeedLookupData(t, db)
e.GET("/api/tasks/templates/", handler.GetTemplates)
t.Run("get all templates", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestTaskTemplateHandler_GetTemplatesGrouped(t *testing.T) {
handler, e, db := setupTaskTemplateHandler(t)
testutil.SeedLookupData(t, db)
e.GET("/api/tasks/templates/grouped/", handler.GetTemplatesGrouped)
t.Run("get grouped templates", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/grouped/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestTaskTemplateHandler_SearchTemplates(t *testing.T) {
handler, e, db := setupTaskTemplateHandler(t)
testutil.SeedLookupData(t, db)
e.GET("/api/tasks/templates/search/", handler.SearchTemplates)
t.Run("missing query returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/search/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("query too short returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/search/?q=a", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("valid query returns 200", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/search/?q=plumbing", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestTaskTemplateHandler_GetTemplatesByCategory(t *testing.T) {
handler, e, db := setupTaskTemplateHandler(t)
testutil.SeedLookupData(t, db)
e.GET("/api/tasks/templates/by-category/:category_id/", handler.GetTemplatesByCategory)
t.Run("valid category id", func(t *testing.T) {
var cat models.TaskCategory
db.First(&cat)
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/templates/by-category/%d/", cat.ID), nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("invalid category id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/by-category/invalid/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// NOTE: TestTaskTemplateHandler_GetTemplatesByRegion was removed. The
// /api/tasks/templates/by-region/ endpoint was deleted; climate-zone
// affinity is now a JSON condition on each template and is scored by the
// main /api/tasks/suggestions/ endpoint (see SuggestionService tests).
func TestTaskTemplateHandler_GetTemplate(t *testing.T) {
handler, e, db := setupTaskTemplateHandler(t)
testutil.SeedLookupData(t, db)
e.GET("/api/tasks/templates/:id/", handler.GetTemplate)
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/invalid/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("non-existent id returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/tasks/templates/99999/", nil, "")
// Service does not wrap gorm.ErrRecordNotFound as a NotFound error,
// so the raw error falls through to the global error handler as 500
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
})
}
// =============================================================================
// Tracking Handler Tests (previously zero coverage)
// =============================================================================
func TestTrackingHandler_TrackEmailOpen(t *testing.T) {
handler := NewTrackingHandler(nil) // nil service -- won't record, but returns pixel
e := testutil.SetupTestRouter()
e.GET("/api/track/open/:trackingID", handler.TrackEmailOpen)
t.Run("returns transparent GIF", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/track/open/test-tracking-id", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
assert.Equal(t, "image/gif", w.Header().Get("Content-Type"))
assert.Equal(t, "no-store, no-cache, must-revalidate, proxy-revalidate", w.Header().Get("Cache-Control"))
assert.Greater(t, w.Body.Len(), 0)
})
t.Run("empty tracking id still returns GIF", func(t *testing.T) {
e2 := testutil.SetupTestRouter()
e2.GET("/api/track/open/", handler.TrackEmailOpen)
w := testutil.MakeRequest(e2, "GET", "/api/track/open/", nil, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
// =============================================================================
// Static Data Handler Tests (previously zero coverage)
// =============================================================================
func setupStaticDataHandler(t *testing.T) (*StaticDataHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
templateRepo := repositories.NewTaskTemplateRepository(db)
cfg := &config.Config{}
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
taskService := services.NewTaskService(taskRepo, residenceRepo)
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
templateService := services.NewTaskTemplateService(templateRepo)
handler := NewStaticDataHandler(residenceService, taskService, contractorService, templateService, nil)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestStaticDataHandler_GetStaticData(t *testing.T) {
handler, e, _ := setupStaticDataHandler(t)
e.GET("/api/static_data/", handler.GetStaticData)
t.Run("returns all lookup data", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/static_data/", nil, "")
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, "residence_types")
assert.Contains(t, response, "task_categories")
assert.Contains(t, response, "task_priorities")
assert.Contains(t, response, "task_frequencies")
assert.Contains(t, response, "contractor_specialties")
assert.Contains(t, response, "task_templates")
})
}
func TestStaticDataHandler_RefreshStaticData(t *testing.T) {
handler, e, _ := setupStaticDataHandler(t)
e.POST("/api/static_data/refresh/", handler.RefreshStaticData)
t.Run("returns success", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/static_data/refresh/", nil, "")
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, "success", response["status"])
})
}
// =============================================================================
// Upload Handler - Additional Error Paths
// =============================================================================
func TestUploadHandler_UploadImage_NoFile(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.POST("/api/uploads/image", handler.UploadImage)
t.Run("no file returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/uploads/image", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestUploadHandler_UploadDocument_NoFile(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.POST("/api/uploads/document", handler.UploadDocument)
t.Run("no file returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/uploads/document", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestUploadHandler_UploadCompletion_NoFile(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.POST("/api/uploads/completion", handler.UploadCompletion)
t.Run("no file returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/uploads/completion", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestUploadHandler_DeleteFile_OwnershipDenied(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
// Mock ownership checker that always denies
checker := &mockOwnershipChecker{owned: false}
handler := NewUploadHandler(storageSvc, checker)
e := testutil.SetupTestRouter()
testUser := &models.User{FirstName: "Test", Email: "test@test.com"}
testUser.ID = 1
authGroup := e.Group("/api")
authGroup.Use(testutil.MockAuthMiddleware(testUser))
authGroup.DELETE("/uploads/", handler.DeleteFile)
t.Run("ownership denied returns 403", func(t *testing.T) {
req := map[string]string{"url": "/uploads/images/test.jpg"}
w := testutil.MakeRequest(e, "DELETE", "/api/uploads/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
// mockOwnershipChecker implements FileOwnershipChecker for testing
type mockOwnershipChecker struct {
owned bool
}
func (m *mockOwnershipChecker) IsFileOwnedByUser(fileURL string, userID uint) (bool, error) {
return m.owned, nil
}