package services import ( "fmt" "net/http" "testing" "time" "github.com/shopspring/decimal" "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/testutil" ) func setupResidenceService(t *testing.T) (*ResidenceService, *repositories.ResidenceRepository, *repositories.UserRepository) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) return service, residenceRepo, userRepo } func TestResidenceService_CreateResidence(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") req := &requests.CreateResidenceRequest{ Name: "Test House", StreetAddress: "123 Main St", City: "Austin", StateProvince: "TX", PostalCode: "78701", } resp, err := service.CreateResidence(req, user.ID) require.NoError(t, err) assert.NotNil(t, resp) assert.Equal(t, "Test House", resp.Data.Name) assert.Equal(t, "123 Main St", resp.Data.StreetAddress) assert.Equal(t, "Austin", resp.Data.City) assert.Equal(t, "TX", resp.Data.StateProvince) assert.Equal(t, "USA", resp.Data.Country) // Default country assert.True(t, resp.Data.IsPrimary) // Default is_primary } func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") bedrooms := 3 bathrooms := decimal.NewFromFloat(2.5) sqft := 2000 isPrimary := false req := &requests.CreateResidenceRequest{ Name: "Test House", StreetAddress: "123 Main St", City: "Austin", StateProvince: "TX", PostalCode: "78701", Country: "Canada", Bedrooms: &bedrooms, Bathrooms: &bathrooms, SquareFootage: &sqft, IsPrimary: &isPrimary, } resp, err := service.CreateResidence(req, user.ID) require.NoError(t, err) assert.Equal(t, "Canada", resp.Data.Country) assert.Equal(t, 3, *resp.Data.Bedrooms) assert.True(t, resp.Data.Bathrooms.Equal(decimal.NewFromFloat(2.5))) assert.Equal(t, 2000, *resp.Data.SquareFootage) // First residence defaults to primary regardless of request assert.True(t, resp.Data.IsPrimary) } func TestResidenceService_GetResidence(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") resp, err := service.GetResidence(residence.ID, user.ID) require.NoError(t, err) assert.Equal(t, residence.ID, resp.ID) assert.Equal(t, "Test House", resp.Name) } func TestResidenceService_GetResidence_AccessDenied(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") _, err := service.GetResidence(residence.ID, otherUser.ID) testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied") } func TestResidenceService_GetResidence_NotFound(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password") _, err := service.GetResidence(9999, user.ID) assert.Error(t, err) } func TestResidenceService_ListResidences(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) 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") resp, err := service.ListResidences(user.ID) require.NoError(t, err) assert.Len(t, resp, 2) } func TestResidenceService_UpdateResidence(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name") newName := "Updated Name" newCity := "Dallas" req := &requests.UpdateResidenceRequest{ Name: &newName, City: &newCity, } resp, err := service.UpdateResidence(residence.ID, user.ID, req) require.NoError(t, err) assert.Equal(t, "Updated Name", resp.Data.Name) assert.Equal(t, "Dallas", resp.Data.City) } func TestResidenceService_UpdateResidence_NotOwner(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) 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, "Test House") // Share with user residenceRepo.AddUser(residence.ID, sharedUser.ID) newName := "Updated" req := &requests.UpdateResidenceRequest{Name: &newName} _, err := service.UpdateResidence(residence.ID, sharedUser.ID, req) testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner") } func TestResidenceService_DeleteResidence(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") _, err := service.DeleteResidence(residence.ID, user.ID) require.NoError(t, err) // Should not be found _, err = service.GetResidence(residence.ID, user.ID) assert.Error(t, err) } func TestResidenceService_DeleteResidence_NotOwner(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) 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, "Test House") residenceRepo.AddUser(residence.ID, sharedUser.ID) _, err := service.DeleteResidence(residence.ID, sharedUser.ID) testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner") } func TestResidenceService_GenerateShareCode(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") resp, err := service.GenerateShareCode(residence.ID, user.ID, 24) require.NoError(t, err) assert.NotEmpty(t, resp.ShareCode.Code) assert.Len(t, resp.ShareCode.Code, 6) } func TestResidenceService_JoinWithCode(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) 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, "Test House") // Generate share code shareResp, err := service.GenerateShareCode(residence.ID, owner.ID, 24) require.NoError(t, err) // Join with code joinResp, err := service.JoinWithCode(shareResp.ShareCode.Code, newUser.ID) require.NoError(t, err) assert.Equal(t, residence.ID, joinResp.Residence.ID) // Verify access hasAccess, _ := residenceRepo.HasAccess(residence.ID, newUser.ID) assert.True(t, hasAccess) } func TestResidenceService_JoinWithCode_AlreadyMember(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") shareResp, _ := service.GenerateShareCode(residence.ID, owner.ID, 24) // Owner tries to join their own residence _, err := service.JoinWithCode(shareResp.ShareCode.Code, owner.ID) testutil.AssertAppError(t, err, http.StatusConflict, "error.user_already_member") } func TestResidenceService_GetResidenceUsers(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") user1 := testutil.CreateTestUser(t, db, "user1", "user1@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") residenceRepo.AddUser(residence.ID, user1.ID) users, err := service.GetResidenceUsers(residence.ID, owner.ID) require.NoError(t, err) assert.Len(t, users, 2) // owner + shared user } func TestResidenceService_RemoveUser(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) 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, "Test House") residenceRepo.AddUser(residence.ID, sharedUser.ID) err := service.RemoveUser(residence.ID, sharedUser.ID, owner.ID) require.NoError(t, err) hasAccess, _ := residenceRepo.HasAccess(residence.ID, sharedUser.ID) assert.False(t, hasAccess) } func TestResidenceService_RemoveUser_CannotRemoveOwner(t *testing.T) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") err := service.RemoveUser(residence.ID, owner.ID, owner.ID) testutil.AssertAppError(t, err, http.StatusBadRequest, "error.cannot_remove_owner") } // setupResidenceServiceWithSubscription creates a ResidenceService wired with a // SubscriptionService, enabling tier limit enforcement in tests. func setupResidenceServiceWithSubscription(t *testing.T) (*ResidenceService, *gorm.DB) { db := testutil.SetupTestDB(t) residenceRepo := repositories.NewResidenceRepository(db) userRepo := repositories.NewUserRepository(db) taskRepo := repositories.NewTaskRepository(db) contractorRepo := repositories.NewContractorRepository(db) documentRepo := repositories.NewDocumentRepository(db) subscriptionRepo := repositories.NewSubscriptionRepository(db) cfg := &config.Config{} service := NewResidenceService(residenceRepo, userRepo, cfg) subscriptionService := NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) service.SetSubscriptionService(subscriptionService) return service, db } func TestCreateResidence_FreeTier_EnforcesLimit(t *testing.T) { service, db := setupResidenceServiceWithSubscription(t) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") // Enable global limitations db.Where("1=1").Delete(&models.SubscriptionSettings{}) err := db.Create(&models.SubscriptionSettings{EnableLimitations: true}).Error require.NoError(t, err) // Set free tier limit to 1 property one := 1 db.Where("tier = ?", models.TierFree).Delete(&models.TierLimits{}) err = db.Create(&models.TierLimits{ Tier: models.TierFree, PropertiesLimit: &one, }).Error require.NoError(t, err) // Ensure user has a free-tier subscription record subscriptionRepo := repositories.NewSubscriptionRepository(db) _, err = subscriptionRepo.GetOrCreate(owner.ID) require.NoError(t, err) // First residence should succeed (under the limit) req := &requests.CreateResidenceRequest{ Name: "First House", StreetAddress: "1 Main St", City: "Austin", StateProvince: "TX", PostalCode: "78701", } resp, err := service.CreateResidence(req, owner.ID) require.NoError(t, err) assert.Equal(t, "First House", resp.Data.Name) // Second residence should be rejected (at the limit) req2 := &requests.CreateResidenceRequest{ Name: "Second House", StreetAddress: "2 Main St", City: "Austin", StateProvince: "TX", PostalCode: "78702", } _, err = service.CreateResidence(req2, owner.ID) testutil.AssertAppError(t, err, http.StatusForbidden, "error.properties_limit_exceeded") } func TestCreateResidence_ProTier_AllowsMore(t *testing.T) { service, db := setupResidenceServiceWithSubscription(t) owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") // Enable global limitations db.Where("1=1").Delete(&models.SubscriptionSettings{}) err := db.Create(&models.SubscriptionSettings{EnableLimitations: true}).Error require.NoError(t, err) // Set free tier limit to 1 property (pro is unlimited by default: nil limits) one := 1 db.Where("tier = ?", models.TierFree).Delete(&models.TierLimits{}) err = db.Create(&models.TierLimits{ Tier: models.TierFree, PropertiesLimit: &one, }).Error require.NoError(t, err) // Create a pro-tier subscription for the user subscriptionRepo := repositories.NewSubscriptionRepository(db) sub, err := subscriptionRepo.GetOrCreate(owner.ID) require.NoError(t, err) // Upgrade to Pro with a future expiration future := time.Now().UTC().Add(30 * 24 * time.Hour) sub.Tier = models.TierPro sub.ExpiresAt = &future sub.SubscribedAt = ptrTime(time.Now().UTC()) err = subscriptionRepo.Update(sub) require.NoError(t, err) // Create multiple residences — all should succeed for Pro users for i := 1; i <= 3; i++ { req := &requests.CreateResidenceRequest{ Name: fmt.Sprintf("House %d", i), StreetAddress: fmt.Sprintf("%d Main St", i), City: "Austin", StateProvince: "TX", PostalCode: "78701", } resp, err := service.CreateResidence(req, owner.ID) require.NoError(t, err, "Pro user should be able to create residence %d", i) assert.Equal(t, fmt.Sprintf("House %d", i), resp.Data.Name) } } // ptrTime returns a pointer to the given time. func ptrTime(t time.Time) *time.Time { return &t }