package handlers import ( "encoding/json" "net/http" "testing" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/dto/requests" "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" "github.com/treytartt/casera-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) }) }