package handlers import ( "encoding/json" "fmt" "net/http" "testing" "github.com/labstack/echo/v4" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/treytartt/casera-api/internal/config" "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" "gorm.io/gorm" ) func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *echo.Echo, *gorm.DB) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) handler := NewResidenceHandler(residenceService, nil, nil) e := testutil.SetupTestRouter() return handler, e, db } func TestResidenceHandler_CreateResidence(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/", handler.CreateResidence) t.Run("successful creation", func(t *testing.T) { req := requests.CreateResidenceRequest{ Name: "My House", StreetAddress: "123 Main St", City: "Austin", StateProvince: "TX", PostalCode: "78701", } w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Response should be wrapped in WithSummaryResponse assert.Contains(t, response, "data") assert.Contains(t, response, "summary") residenceData := response["data"].(map[string]interface{}) assert.Equal(t, "My House", residenceData["name"]) assert.Equal(t, "123 Main St", residenceData["street_address"]) assert.Equal(t, "Austin", residenceData["city"]) assert.Equal(t, "TX", residenceData["state_province"]) assert.Equal(t, "78701", residenceData["postal_code"]) assert.Equal(t, "USA", residenceData["country"]) // Default assert.Equal(t, true, residenceData["is_primary"]) }) t.Run("creation with optional fields", func(t *testing.T) { bedrooms := 3 bathrooms := decimal.NewFromFloat(2.5) sqft := 2000 isPrimary := false req := requests.CreateResidenceRequest{ Name: "Second House", StreetAddress: "456 Oak Ave", City: "Dallas", StateProvince: "TX", PostalCode: "75001", Country: "USA", Bedrooms: &bedrooms, Bathrooms: &bathrooms, SquareFootage: &sqft, IsPrimary: &isPrimary, } w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Response should be wrapped in WithSummaryResponse assert.Contains(t, response, "data") assert.Contains(t, response, "summary") residenceData := response["data"].(map[string]interface{}) assert.Equal(t, float64(3), residenceData["bedrooms"]) assert.Equal(t, "2.5", residenceData["bathrooms"]) // Decimal serializes as string assert.Equal(t, float64(2000), residenceData["square_footage"]) // Note: first residence becomes primary by default even if is_primary=false is specified assert.Contains(t, []interface{}{true, false}, residenceData["is_primary"]) }) t.Run("creation with missing required fields", func(t *testing.T) { // Only name is required; address fields are optional req := map[string]string{ // Missing name - this is required } w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) } func TestResidenceHandler_GetResidence(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/:id/", handler.GetResidence) otherAuthGroup := e.Group("/api/other-residences") otherAuthGroup.Use(testutil.MockAuthMiddleware(otherUser)) otherAuthGroup.GET("/:id/", handler.GetResidence) t.Run("get own residence", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%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.Equal(t, "Test House", response["name"]) assert.Equal(t, float64(residence.ID), response["id"]) }) t.Run("get residence with invalid ID", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/residences/invalid/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("get non-existent residence", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/residences/9999/", nil, "test-token") // Returns 403 (access denied) rather than 404 to not reveal whether an ID exists testutil.AssertStatusCode(t, w, http.StatusForbidden) }) t.Run("access denied for other user", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusForbidden) }) } func TestResidenceHandler_ListResidences(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") testutil.CreateTestResidence(t, db, user.ID, "House 1") testutil.CreateTestResidence(t, db, user.ID, "House 2") authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/", handler.ListResidences) t.Run("list residences", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/residences/", 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) }) } func TestResidenceHandler_UpdateResidence(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name") // Share with user residenceRepo := repositories.NewResidenceRepository(db) residenceRepo.AddUser(residence.ID, sharedUser.ID) authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.PUT("/:id/", handler.UpdateResidence) sharedGroup := e.Group("/api/shared-residences") sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser)) sharedGroup.PUT("/:id/", handler.UpdateResidence) t.Run("owner can update", func(t *testing.T) { newName := "Updated Name" newCity := "Dallas" req := requests.UpdateResidenceRequest{ Name: &newName, City: &newCity, } w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Response should be wrapped in WithSummaryResponse assert.Contains(t, response, "data") assert.Contains(t, response, "summary") residenceData := response["data"].(map[string]interface{}) assert.Equal(t, "Updated Name", residenceData["name"]) assert.Equal(t, "Dallas", residenceData["city"]) }) t.Run("shared user cannot update", func(t *testing.T) { newName := "Hacked Name" req := requests.UpdateResidenceRequest{ Name: &newName, } w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token") testutil.AssertStatusCode(t, w, http.StatusForbidden) }) } func TestResidenceHandler_DeleteResidence(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "To Delete") residenceRepo := repositories.NewResidenceRepository(db) residenceRepo.AddUser(residence.ID, sharedUser.ID) authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.DELETE("/:id/", handler.DeleteResidence) sharedGroup := e.Group("/api/shared-residences") sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser)) sharedGroup.DELETE("/:id/", handler.DeleteResidence) t.Run("shared user cannot delete", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusForbidden) }) t.Run("owner can delete", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%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) // Response should be wrapped in WithSummaryResponse assert.Contains(t, response, "data") assert.Contains(t, response, "summary") assert.Contains(t, response["data"], "deleted") }) } func TestResidenceHandler_GenerateShareCode(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Share Test") authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode) t.Run("generate share code", func(t *testing.T) { req := requests.GenerateShareCodeRequest{ ExpiresInHours: 24, } w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.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) shareCode := response["share_code"].(map[string]interface{}) code := shareCode["code"].(string) assert.Len(t, code, 6) assert.NotEmpty(t, shareCode["expires_at"]) }) } func TestResidenceHandler_JoinWithCode(t *testing.T) { handler, e, db := setupResidenceHandler(t) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Join Test") // Generate share code first residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) shareResp, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24) authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(newUser)) authGroup.POST("/join-with-code/", handler.JoinWithCode) ownerGroup := e.Group("/api/owner-residences") ownerGroup.Use(testutil.MockAuthMiddleware(owner)) ownerGroup.POST("/join-with-code/", handler.JoinWithCode) t.Run("join with valid code", func(t *testing.T) { req := requests.JoinWithCodeRequest{ Code: shareResp.ShareCode.Code, } w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", 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) // JoinResidenceResponse includes summary assert.Contains(t, response, "residence") assert.Contains(t, response, "summary") residenceResp := response["residence"].(map[string]interface{}) assert.Equal(t, "Join Test", residenceResp["name"]) }) t.Run("owner tries to join own residence", func(t *testing.T) { // Generate new code shareResp2, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24) req := requests.JoinWithCodeRequest{ Code: shareResp2.ShareCode.Code, } w := testutil.MakeRequest(e, "POST", "/api/owner-residences/join-with-code/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusConflict) }) t.Run("join with invalid code", func(t *testing.T) { req := requests.JoinWithCodeRequest{ Code: "ABCDEF", // Valid length (6) but non-existent code } w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusNotFound) }) } func TestResidenceHandler_GetResidenceUsers(t *testing.T) { handler, e, db := setupResidenceHandler(t) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Users Test") residenceRepo := repositories.NewResidenceRepository(db) residenceRepo.AddUser(residence.ID, sharedUser.ID) authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(owner)) authGroup.GET("/:id/users/", handler.GetResidenceUsers) t.Run("get residence users", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/users/", 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, 2) // owner + shared user }) } func TestResidenceHandler_RemoveUser(t *testing.T) { handler, e, db := setupResidenceHandler(t) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Remove Test") residenceRepo := repositories.NewResidenceRepository(db) residenceRepo.AddUser(residence.ID, sharedUser.ID) authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(owner)) authGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser) t.Run("remove shared user", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) response := testutil.ParseJSON(t, w.Body.Bytes()) assert.Contains(t, response["message"], "removed") }) t.Run("cannot remove owner", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) } func TestResidenceHandler_GetResidenceTypes(t *testing.T) { handler, e, db := setupResidenceHandler(t) testutil.SeedLookupData(t, db) user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password") authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/types/", handler.GetResidenceTypes) t.Run("get residence types", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/residences/types/", 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 TestResidenceHandler_JSONResponses(t *testing.T) { handler, e, db := setupResidenceHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") authGroup := e.Group("/api/residences") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/", handler.CreateResidence) authGroup.GET("/", handler.ListResidences) t.Run("residence response has correct JSON structure", func(t *testing.T) { req := requests.CreateResidenceRequest{ Name: "JSON Test House", StreetAddress: "123 Test St", City: "Austin", StateProvince: "TX", PostalCode: "78701", } w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Response should be wrapped in WithSummaryResponse assert.Contains(t, response, "data") assert.Contains(t, response, "summary") residenceData := response["data"].(map[string]interface{}) // Required fields in residence data assert.Contains(t, residenceData, "id") assert.Contains(t, residenceData, "name") assert.Contains(t, residenceData, "street_address") assert.Contains(t, residenceData, "city") assert.Contains(t, residenceData, "state_province") assert.Contains(t, residenceData, "postal_code") assert.Contains(t, residenceData, "country") assert.Contains(t, residenceData, "is_primary") assert.Contains(t, residenceData, "is_active") assert.Contains(t, residenceData, "created_at") assert.Contains(t, residenceData, "updated_at") // Type checks assert.IsType(t, float64(0), residenceData["id"]) assert.IsType(t, "", residenceData["name"]) assert.IsType(t, true, residenceData["is_primary"]) // Summary should have expected fields summary := response["summary"].(map[string]interface{}) assert.Contains(t, summary, "total_residences") assert.Contains(t, summary, "total_tasks") }) t.Run("list response returns array", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response []map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Response should be an array of residences assert.IsType(t, []map[string]interface{}{}, response) }) }