- Add completion_summary endpoint data to residence detail response - Track completed_from_column on task completions (overdue/due_soon/upcoming) - Add GetCompletionSummary repo method with monthly aggregation - Add one-time data migration framework (data_migrations table + registry) - Add backfill migration to classify historical completions - Add standalone backfill script for manual/dry-run usage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
459 lines
16 KiB
Go
459 lines
16 KiB
Go
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, time.Now())
|
|
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, time.Now())
|
|
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, time.Now())
|
|
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, time.Now())
|
|
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
|
|
}
|