Files
honeyDueAPI/internal/handlers/contractor_handler_test.go
Trey T bec880886b Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests)
- Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests)
- Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests)
- Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests)
- Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests)
- Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:30:09 -05:00

465 lines
17 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func setupContractorHandler(t *testing.T) (*ContractorHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
handler := NewContractorHandler(contractorService)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestContractorHandler_CreateContractor_MissingName_Returns400(t *testing.T) {
handler, e, db := setupContractorHandler(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/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateContractor)
t.Run("missing name returns 400 validation error", func(t *testing.T) {
// Send request with no name (required field)
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", 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)
// 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, "name", "validation error should reference the 'name' field")
})
t.Run("empty body returns 400 validation error", func(t *testing.T) {
// Send completely empty body
w := testutil.MakeRequest(e, "POST", "/api/contractors/", 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)
assert.Contains(t, response, "error")
})
t.Run("valid contractor creation succeeds", func(t *testing.T) {
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "John the Plumber",
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
})
}
func TestContractorHandler_ListContractors_Error_NoRawErrorInResponse(t *testing.T) {
_, e, db := setupContractorHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a handler with a broken service to simulate an internal error.
// We do this by closing the underlying SQL connection, which will cause
// the service to return an error on the next query.
brokenDB := testutil.SetupTestDB(t)
sqlDB, _ := brokenDB.DB()
sqlDB.Close()
brokenContractorRepo := repositories.NewContractorRepository(brokenDB)
brokenResidenceRepo := repositories.NewResidenceRepository(brokenDB)
brokenService := services.NewContractorService(brokenContractorRepo, brokenResidenceRepo)
brokenHandler := NewContractorHandler(brokenService)
authGroup := e.Group("/api/broken-contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", brokenHandler.ListContractors)
t.Run("internal error does not leak raw error message", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/broken-contractors/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Should contain the generic error key, NOT a raw database error
errorMsg, ok := response["error"].(string)
require.True(t, ok, "response should have an 'error' string field")
// Must not contain database-specific details
assert.NotContains(t, errorMsg, "sql", "error message should not leak SQL details")
assert.NotContains(t, errorMsg, "database", "error message should not leak database details")
assert.NotContains(t, errorMsg, "closed", "error message should not leak connection state")
})
}
func TestContractorHandler_CreateContractor_100Specialties_Returns400(t *testing.T) {
handler, e, db := setupContractorHandler(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/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateContractor)
t.Run("too many specialties rejected", func(t *testing.T) {
// Create a slice with 100 specialty IDs (exceeds max=20)
specialtyIDs := make([]uint, 100)
for i := range specialtyIDs {
specialtyIDs[i] = uint(i + 1)
}
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Over-specialized Contractor",
SpecialtyIDs: specialtyIDs,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("20 specialties accepted", func(t *testing.T) {
specialtyIDs := make([]uint, 20)
for i := range specialtyIDs {
specialtyIDs[i] = uint(i + 1)
}
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Multi-skilled Contractor",
SpecialtyIDs: specialtyIDs,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
// Should pass validation (201 or success, not 400)
assert.NotEqual(t, http.StatusBadRequest, w.Code, "20 specialties should pass validation")
})
t.Run("rating above 5 rejected", func(t *testing.T) {
rating := 6.0
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Bad Rating Contractor",
Rating: &rating,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestContractorHandler_ListContractors(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Electrician Bob")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListContractors)
t.Run("successful list", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/", 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, 2)
})
t.Run("user with no contractors returns empty", func(t *testing.T) {
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
e2 := testutil.SetupTestRouter()
authGroup2 := e2.Group("/api/contractors")
authGroup2.Use(testutil.MockAuthMiddleware(otherUser))
authGroup2.GET("/", handler.ListContractors)
w := testutil.MakeRequest(e2, "GET", "/api/contractors/", 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)
})
}
func TestContractorHandler_GetContractor(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetContractor)
t.Run("successful get", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/contractors/%d/", contractor.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, "Plumber Joe", response["name"])
})
t.Run("not found returns 404", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/99999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestContractorHandler_UpdateContractor(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateContractor)
t.Run("successful update", func(t *testing.T) {
newName := "Plumber Joe Updated"
req := requests.UpdateContractorRequest{
Name: &newName,
}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/contractors/%d/", contractor.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, "Plumber Joe Updated", response["name"])
})
t.Run("invalid id returns 400", func(t *testing.T) {
newName := "Updated"
req := requests.UpdateContractorRequest{Name: &newName}
w := testutil.MakeRequest(e, "PUT", "/api/contractors/invalid/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("not found returns 404", func(t *testing.T) {
newName := "Updated"
req := requests.UpdateContractorRequest{Name: &newName}
w := testutil.MakeRequest(e, "PUT", "/api/contractors/99999/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestContractorHandler_DeleteContractor(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteContractor)
t.Run("successful delete", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/contractors/%d/", contractor.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, "message")
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", "/api/contractors/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/contractors/99999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestContractorHandler_ToggleFavorite(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/toggle-favorite/", handler.ToggleFavorite)
t.Run("toggle favorite on", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/contractors/%d/toggle-favorite/", contractor.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, "is_favorite")
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/contractors/invalid/toggle-favorite/", 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/contractors/99999/toggle-favorite/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestContractorHandler_ListContractorsByResidence(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/by-residence/:residence_id/", handler.ListContractorsByResidence)
t.Run("successful list by residence", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/contractors/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.Len(t, response, 1)
})
t.Run("invalid residence id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/by-residence/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestContractorHandler_GetSpecialties(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("/specialties/", handler.GetSpecialties)
t.Run("successful list specialties", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/specialties/", 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 TestContractorHandler_GetContractorTasks(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Plumber Joe")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/tasks/", handler.GetContractorTasks)
t.Run("successful get tasks", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/contractors/%d/tasks/", contractor.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("invalid id returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/contractors/invalid/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestContractorHandler_CreateContractor_WithOptionalFields(t *testing.T) {
handler, e, db := setupContractorHandler(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/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateContractor)
t.Run("creation with all optional fields", func(t *testing.T) {
rating := 4.5
isFavorite := true
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Full Contractor",
Company: "ABC Plumbing",
Phone: "555-1234",
Email: "contractor@test.com",
Notes: "Great work",
Rating: &rating,
IsFavorite: &isFavorite,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", 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)
assert.Equal(t, "Full Contractor", response["name"])
assert.Equal(t, "ABC Plumbing", response["company"])
})
}