Merge branch 'master' of github.com:akatreyt/MyCribAPI_GO
Some checks failed
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

This commit is contained in:
Trey t
2026-04-01 20:45:43 -05:00
83 changed files with 19569 additions and 730 deletions

View File

@@ -35,7 +35,7 @@ func createRefreshTestUser(t *testing.T, db *gorm.DB) *models.User {
Email: "refresh@test.com",
IsActive: true,
}
require.NoError(t, user.SetPassword("password123"))
require.NoError(t, user.SetPassword("Password123"))
require.NoError(t, db.Create(user).Error)
return user
}

View File

@@ -0,0 +1,800 @@
package services
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func setupAuthService(t *testing.T) (*AuthService, *repositories.UserRepository) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
notifRepo := repositories.NewNotificationRepository(db)
cfg := &config.Config{
Server: config.ServerConfig{
DebugFixedCodes: true,
},
Security: config.SecurityConfig{
SecretKey: "test-secret",
ConfirmationExpiry: 24 * time.Hour,
PasswordResetExpiry: 15 * time.Minute,
MaxPasswordResetRate: 3,
TokenExpiryDays: 90,
TokenRefreshDays: 60,
},
}
service := NewAuthService(userRepo, cfg)
service.SetNotificationRepository(notifRepo)
return service, userRepo
}
// === Login ===
func TestAuthService_Login(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
req := &requests.LoginRequest{
Username: "testuser",
Password: "Password123",
}
resp, err := service.Login(req)
require.NoError(t, err)
assert.NotEmpty(t, resp.Token)
assert.Equal(t, "testuser", resp.User.Username)
}
func TestAuthService_Login_ByEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
req := &requests.LoginRequest{
Email: "test@test.com",
Password: "Password123",
}
resp, err := service.Login(req)
require.NoError(t, err)
assert.NotEmpty(t, resp.Token)
}
func TestAuthService_Login_InvalidCredentials(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
req := &requests.LoginRequest{
Username: "testuser",
Password: "WrongPassword1",
}
_, err := service.Login(req)
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
}
func TestAuthService_Login_UserNotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
req := &requests.LoginRequest{
Username: "nonexistent",
Password: "Password123",
}
_, err := service.Login(req)
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
}
func TestAuthService_Login_InactiveUser(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
user := testutil.CreateTestUser(t, db, "inactive", "inactive@test.com", "Password123")
// Deactivate
user.IsActive = false
db.Save(user)
req := &requests.LoginRequest{
Username: "inactive",
Password: "Password123",
}
_, err := service.Login(req)
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.account_inactive")
}
// === Register ===
func TestAuthService_Register(t *testing.T) {
service, _ := setupAuthService(t)
req := &requests.RegisterRequest{
Username: "newuser",
Email: "new@test.com",
Password: "Password123",
}
resp, code, err := service.Register(req)
require.NoError(t, err)
assert.NotEmpty(t, resp.Token)
assert.Equal(t, "newuser", resp.User.Username)
assert.Equal(t, "123456", code) // DebugFixedCodes=true
}
func TestAuthService_Register_DuplicateUsername(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Server: config.ServerConfig{DebugFixedCodes: true},
Security: config.SecurityConfig{SecretKey: "test", ConfirmationExpiry: 24 * time.Hour},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "taken", "taken@test.com", "Password123")
req := &requests.RegisterRequest{
Username: "taken",
Email: "different@test.com",
Password: "Password123",
}
_, _, err := service.Register(req)
testutil.AssertAppError(t, err, http.StatusConflict, "error.username_taken")
}
func TestAuthService_Register_DuplicateEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Server: config.ServerConfig{DebugFixedCodes: true},
Security: config.SecurityConfig{SecretKey: "test", ConfirmationExpiry: 24 * time.Hour},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "existing", "taken@test.com", "Password123")
req := &requests.RegisterRequest{
Username: "newuser",
Email: "taken@test.com",
Password: "Password123",
}
_, _, err := service.Register(req)
testutil.AssertAppError(t, err, http.StatusConflict, "error.email_taken")
}
// === GetCurrentUser ===
func TestAuthService_GetCurrentUser(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
// Create profile
userRepo.GetOrCreateProfile(user.ID)
resp, err := service.GetCurrentUser(user.ID)
require.NoError(t, err)
assert.Equal(t, "testuser", resp.Username)
assert.Equal(t, "test@test.com", resp.Email)
assert.Equal(t, "email", resp.AuthProvider) // Default for no social auth
}
// === UpdateProfile ===
func TestAuthService_UpdateProfile(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
userRepo.GetOrCreateProfile(user.ID)
newFirst := "John"
newLast := "Doe"
req := &requests.UpdateProfileRequest{
FirstName: &newFirst,
LastName: &newLast,
}
resp, err := service.UpdateProfile(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "John", resp.FirstName)
assert.Equal(t, "Doe", resp.LastName)
}
func TestAuthService_UpdateProfile_DuplicateEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "user1", "user1@test.com", "Password123")
user2 := testutil.CreateTestUser(t, db, "user2", "user2@test.com", "Password123")
userRepo.GetOrCreateProfile(user2.ID)
takenEmail := "user1@test.com"
req := &requests.UpdateProfileRequest{
Email: &takenEmail,
}
_, err := service.UpdateProfile(user2.ID, req)
testutil.AssertAppError(t, err, http.StatusConflict, "error.email_already_taken")
}
func TestAuthService_UpdateProfile_SameEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
userRepo.GetOrCreateProfile(user.ID)
sameEmail := "test@test.com"
req := &requests.UpdateProfileRequest{
Email: &sameEmail,
}
// Same email should not trigger duplicate error
resp, err := service.UpdateProfile(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "test@test.com", resp.Email)
}
// === VerifyEmail ===
func TestAuthService_VerifyEmail(t *testing.T) {
service, _ := setupAuthService(t)
// Register a user (creates confirmation code)
req := &requests.RegisterRequest{
Username: "newuser",
Email: "new@test.com",
Password: "Password123",
}
_, _, err := service.Register(req)
require.NoError(t, err)
// Get the user ID
user, err := service.userRepo.FindByEmail("new@test.com")
require.NoError(t, err)
// Verify with the debug code
err = service.VerifyEmail(user.ID, "123456")
require.NoError(t, err)
// Verify again — should get already verified error
err = service.VerifyEmail(user.ID, "123456")
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.email_already_verified")
}
func TestAuthService_VerifyEmail_InvalidCode(t *testing.T) {
service, _ := setupAuthService(t)
// Register
req := &requests.RegisterRequest{
Username: "newuser",
Email: "new@test.com",
Password: "Password123",
}
_, _, err := service.Register(req)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("new@test.com")
require.NoError(t, err)
// Wrong code — with DebugFixedCodes enabled, "123456" bypasses normal lookup,
// but a wrong code should use the normal path
err = service.VerifyEmail(user.ID, "000000")
assert.Error(t, err)
}
// === ResendVerificationCode ===
func TestAuthService_ResendVerificationCode(t *testing.T) {
service, _ := setupAuthService(t)
// Register
req := &requests.RegisterRequest{
Username: "newuser",
Email: "new@test.com",
Password: "Password123",
}
_, _, err := service.Register(req)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("new@test.com")
require.NoError(t, err)
code, err := service.ResendVerificationCode(user.ID)
require.NoError(t, err)
assert.Equal(t, "123456", code) // DebugFixedCodes
}
func TestAuthService_ResendVerificationCode_AlreadyVerified(t *testing.T) {
service, _ := setupAuthService(t)
// Register and verify
req := &requests.RegisterRequest{
Username: "newuser",
Email: "new@test.com",
Password: "Password123",
}
_, _, err := service.Register(req)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("new@test.com")
require.NoError(t, err)
err = service.VerifyEmail(user.ID, "123456")
require.NoError(t, err)
_, err = service.ResendVerificationCode(user.ID)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.email_already_verified")
}
// === ForgotPassword ===
func TestAuthService_ForgotPassword(t *testing.T) {
service, _ := setupAuthService(t)
// Register a user first
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
code, user, err := service.ForgotPassword("test@test.com")
require.NoError(t, err)
assert.Equal(t, "123456", code) // DebugFixedCodes
assert.NotNil(t, user)
assert.Equal(t, "test@test.com", user.Email)
}
func TestAuthService_ForgotPassword_NonexistentEmail(t *testing.T) {
service, _ := setupAuthService(t)
// Should not reveal that email doesn't exist
code, user, err := service.ForgotPassword("nonexistent@test.com")
require.NoError(t, err)
assert.Empty(t, code)
assert.Nil(t, user)
}
// === ResetPassword ===
func TestAuthService_ResetPassword(t *testing.T) {
service, _ := setupAuthService(t)
// Register
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
// Forgot password
_, _, err = service.ForgotPassword("test@test.com")
require.NoError(t, err)
// Verify reset code to get the token
resetToken, err := service.VerifyResetCode("test@test.com", "123456")
require.NoError(t, err)
assert.NotEmpty(t, resetToken)
// Reset password
err = service.ResetPassword(resetToken, "NewPassword123")
require.NoError(t, err)
// Login with new password
loginReq := &requests.LoginRequest{
Username: "testuser",
Password: "NewPassword123",
}
loginResp, err := service.Login(loginReq)
require.NoError(t, err)
assert.NotEmpty(t, loginResp.Token)
}
func TestAuthService_ResetPassword_InvalidToken(t *testing.T) {
service, _ := setupAuthService(t)
err := service.ResetPassword("invalid-token", "NewPassword123")
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.invalid_reset_token")
}
// === Logout ===
func TestAuthService_Logout(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
// Login first
loginReq := &requests.LoginRequest{
Username: "testuser",
Password: "Password123",
}
loginResp, err := service.Login(loginReq)
require.NoError(t, err)
// Logout
err = service.Logout(loginResp.Token)
require.NoError(t, err)
// Token should be deleted — refreshing should fail
_, err = service.RefreshToken(loginResp.Token, user.ID)
assert.Error(t, err)
}
// === DeleteAccount ===
func TestAuthService_DeleteAccount_EmailAuth(t *testing.T) {
service, _ := setupAuthService(t)
// Register
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("test@test.com")
require.NoError(t, err)
password := "Password123"
_, err = service.DeleteAccount(user.ID, &password, nil)
require.NoError(t, err)
}
func TestAuthService_DeleteAccount_WrongPassword(t *testing.T) {
service, _ := setupAuthService(t)
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("test@test.com")
require.NoError(t, err)
wrongPassword := "WrongPassword1"
_, err = service.DeleteAccount(user.ID, &wrongPassword, nil)
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
}
func TestAuthService_DeleteAccount_NoPassword(t *testing.T) {
service, _ := setupAuthService(t)
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("test@test.com")
require.NoError(t, err)
_, err = service.DeleteAccount(user.ID, nil, nil)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.password_required")
}
func TestAuthService_DeleteAccount_UserNotFound(t *testing.T) {
service, _ := setupAuthService(t)
password := "Password123"
_, err := service.DeleteAccount(99999, &password, nil)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.user_not_found")
}
// === Helper functions ===
func TestGenerateSixDigitCode(t *testing.T) {
code := generateSixDigitCode()
assert.Len(t, code, 6)
// Should be numeric
for _, c := range code {
assert.True(t, c >= '0' && c <= '9', "code should contain only digits")
}
}
func TestGenerateResetToken(t *testing.T) {
token := generateResetToken()
assert.NotEmpty(t, token)
assert.Len(t, token, 64) // 32 bytes = 64 hex chars
}
func TestGetStringOrEmpty(t *testing.T) {
s := "hello"
assert.Equal(t, "hello", getStringOrEmpty(&s))
assert.Equal(t, "", getStringOrEmpty(nil))
}
func TestIsPrivateRelayEmail(t *testing.T) {
assert.True(t, isPrivateRelayEmail("abc@privaterelay.appleid.com"))
assert.True(t, isPrivateRelayEmail("ABC@PRIVATERELAY.APPLEID.COM"))
assert.False(t, isPrivateRelayEmail("user@gmail.com"))
}
func TestGetEmailFromRequest(t *testing.T) {
email := "req@test.com"
assert.Equal(t, "req@test.com", getEmailFromRequest(&email, "claims@test.com"))
assert.Equal(t, "claims@test.com", getEmailFromRequest(nil, "claims@test.com"))
empty := ""
assert.Equal(t, "claims@test.com", getEmailFromRequest(&empty, "claims@test.com"))
}
// === getEmailOrDefault ===
func TestGetEmailOrDefault(t *testing.T) {
// Non-empty email returns itself
assert.Equal(t, "user@test.com", getEmailOrDefault("user@test.com"))
// Empty email returns a generated placeholder
result := getEmailOrDefault("")
assert.Contains(t, result, "@privaterelay.appleid.com")
assert.Contains(t, result, "apple_")
}
// === generateUniqueUsername ===
func TestGenerateUniqueUsername(t *testing.T) {
// Normal email generates username from email prefix
username := generateUniqueUsername("john@test.com", nil)
assert.Contains(t, username, "john_")
// Private relay email falls back to first name
firstName := "Jane"
username = generateUniqueUsername("abc@privaterelay.appleid.com", &firstName)
assert.Contains(t, username, "jane_")
// Private relay email and no first name — fallback
username = generateUniqueUsername("abc@privaterelay.appleid.com", nil)
assert.Contains(t, username, "user_")
// Empty email with first name
firstName2 := "Bob"
username = generateUniqueUsername("", &firstName2)
assert.Contains(t, username, "bob_")
// Empty email and no first name
username = generateUniqueUsername("", nil)
assert.Contains(t, username, "user_")
}
// === generateGoogleUsername ===
func TestGenerateGoogleUsername(t *testing.T) {
// Normal email
username := generateGoogleUsername("john@gmail.com", "John")
assert.Contains(t, username, "john_")
// Empty email falls back to first name
username = generateGoogleUsername("", "Alice")
assert.Contains(t, username, "alice_")
// Empty email and empty first name — fallback
username = generateGoogleUsername("", "")
assert.Contains(t, username, "google_")
}
// === Login with empty password ===
func TestAuthService_Login_EmptyPassword(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
req := &requests.LoginRequest{
Username: "testuser",
Password: "",
}
_, err := service.Login(req)
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
}
// === ForgotPassword rate limiting ===
func TestAuthService_ForgotPassword_RateLimit(t *testing.T) {
service, _ := setupAuthService(t)
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
// Make max allowed reset requests (3 based on setup)
for i := 0; i < 3; i++ {
_, _, err := service.ForgotPassword("test@test.com")
require.NoError(t, err)
}
// The 4th should be rate limited
_, _, err = service.ForgotPassword("test@test.com")
assert.Error(t, err)
}
// === VerifyResetCode with wrong code ===
func TestAuthService_VerifyResetCode_WrongCode(t *testing.T) {
service, _ := setupAuthService(t)
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
_, _, err = service.ForgotPassword("test@test.com")
require.NoError(t, err)
// Wrong code but with debug mode, "123456" works, "000000" should fail
_, err = service.VerifyResetCode("test@test.com", "000000")
assert.Error(t, err)
}
// === VerifyResetCode with nonexistent email ===
func TestAuthService_VerifyResetCode_NonexistentEmail(t *testing.T) {
service, _ := setupAuthService(t)
_, err := service.VerifyResetCode("nonexistent@test.com", "123456")
assert.Error(t, err)
}
// === UpdateProfile — change email to new email ===
func TestAuthService_UpdateProfile_ChangeEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
userRepo.GetOrCreateProfile(user.ID)
newEmail := "newemail@test.com"
req := &requests.UpdateProfileRequest{
Email: &newEmail,
}
resp, err := service.UpdateProfile(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "newemail@test.com", resp.Email)
}
// === DeleteAccount — empty password string ===
func TestAuthService_DeleteAccount_EmptyPassword(t *testing.T) {
service, _ := setupAuthService(t)
registerReq := &requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "Password123",
}
_, _, err := service.Register(registerReq)
require.NoError(t, err)
user, err := service.userRepo.FindByEmail("test@test.com")
require.NoError(t, err)
emptyPw := ""
_, err = service.DeleteAccount(user.ID, &emptyPw, nil)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.password_required")
}
// === SetNotificationRepository ===
func TestAuthService_SetNotificationRepository(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
notifRepo := repositories.NewNotificationRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{SecretKey: "test-secret"},
}
service := NewAuthService(userRepo, cfg)
assert.Nil(t, service.notificationRepo)
service.SetNotificationRepository(notifRepo)
assert.NotNil(t, service.notificationRepo)
}
// === Register creates profile and notification preferences ===
func TestAuthService_Register_CreatesProfile(t *testing.T) {
service, userRepo := setupAuthService(t)
req := &requests.RegisterRequest{
Username: "profileuser",
Email: "profile@test.com",
Password: "Password123",
FirstName: "John",
LastName: "Doe",
}
resp, _, err := service.Register(req)
require.NoError(t, err)
assert.Equal(t, "profileuser", resp.User.Username)
// Profile should exist
profile, err := userRepo.GetOrCreateProfile(resp.User.ID)
require.NoError(t, err)
assert.NotNil(t, profile)
}

View File

@@ -4,9 +4,11 @@ import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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"
)
@@ -20,6 +22,420 @@ func setupContractorService(t *testing.T) (*ContractorService, *repositories.Con
return service, contractorRepo, residenceRepo
}
// === CreateContractor ===
func TestContractorService_CreateContractor(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Bob's Plumbing",
Phone: "555-1234",
Email: "bob@plumbing.com",
}
resp, err := service.CreateContractor(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Bob's Plumbing", resp.Name)
assert.Equal(t, "555-1234", resp.Phone)
assert.Equal(t, "bob@plumbing.com", resp.Email)
}
func TestContractorService_CreateContractor_Personal(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// No residence ID - personal contractor
req := &requests.CreateContractorRequest{
Name: "Personal Handyman",
}
resp, err := service.CreateContractor(req, user.ID)
require.NoError(t, err)
assert.Equal(t, "Personal Handyman", resp.Name)
}
func TestContractorService_CreateContractor_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
req := &requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Unauthorized Contractor",
}
_, err := service.CreateContractor(req, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
func TestContractorService_CreateContractor_WithFavorite(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
isFav := true
req := &requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Fav Plumber",
IsFavorite: &isFav,
}
resp, err := service.CreateContractor(req, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsFavorite)
}
// === GetContractor ===
func TestContractorService_GetContractor(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Test Contractor")
resp, err := service.GetContractor(contractor.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, contractor.ID, resp.ID)
assert.Equal(t, "Test Contractor", resp.Name)
}
func TestContractorService_GetContractor_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.GetContractor(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.contractor_not_found")
}
func TestContractorService_GetContractor_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "Private Contractor")
_, err := service.GetContractor(contractor.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
}
func TestContractorService_GetContractor_SharedUserHasAccess(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "Shared Contractor")
resp, err := service.GetContractor(contractor.ID, shared.ID)
require.NoError(t, err)
assert.Equal(t, "Shared Contractor", resp.Name)
}
// === ListContractors ===
func TestContractorService_ListContractors(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Contractor 1")
testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Contractor 2")
resp, err := service.ListContractors(user.ID)
require.NoError(t, err)
assert.Len(t, resp, 2)
}
// === DeleteContractor ===
func TestContractorService_DeleteContractor(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "To Delete")
err := service.DeleteContractor(contractor.ID, user.ID)
require.NoError(t, err)
// Should not be found after deletion
_, err = service.GetContractor(contractor.ID, user.ID)
assert.Error(t, err)
}
func TestContractorService_DeleteContractor_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.DeleteContractor(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.contractor_not_found")
}
func TestContractorService_DeleteContractor_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "Private Contractor")
err := service.DeleteContractor(contractor.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
}
// === ToggleFavorite ===
func TestContractorService_ToggleFavorite(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Test Contractor")
// Initially not favorite
resp, err := service.GetContractor(contractor.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsFavorite)
// Toggle to favorite
resp, err = service.ToggleFavorite(contractor.ID, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsFavorite)
// Toggle back
resp, err = service.ToggleFavorite(contractor.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsFavorite)
}
func TestContractorService_ToggleFavorite_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.ToggleFavorite(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.contractor_not_found")
}
func TestContractorService_ToggleFavorite_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "Private Contractor")
_, err := service.ToggleFavorite(contractor.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
}
// === ListContractorsByResidence ===
func TestContractorService_ListContractorsByResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Contractor A")
testutil.CreateTestContractor(t, db, residence.ID, user.ID, "Contractor B")
resp, err := service.ListContractorsByResidence(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp, 2)
}
func TestContractorService_ListContractorsByResidence_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
_, err := service.ListContractorsByResidence(residence.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
// === GetContractorTasks ===
func TestContractorService_GetContractorTasks_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.GetContractorTasks(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.contractor_not_found")
}
func TestContractorService_GetContractorTasks_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "Private Contractor")
_, err := service.GetContractorTasks(contractor.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
}
func TestContractorService_GetContractorTasks_Empty(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Test Contractor")
resp, err := service.GetContractorTasks(contractor.ID, user.ID)
require.NoError(t, err)
assert.Empty(t, resp)
}
// === GetSpecialties ===
func TestContractorService_GetSpecialties(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
resp, err := service.GetSpecialties()
require.NoError(t, err)
// SeedLookupData creates 4 specialties
assert.Len(t, resp, 4)
}
// === UpdateContractor ===
func TestContractorService_UpdateContractor_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
newName := "Won't Work"
req := &requests.UpdateContractorRequest{Name: &newName}
_, err := service.UpdateContractor(9999, user.ID, req)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.contractor_not_found")
}
func TestContractorService_UpdateContractor_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "Private Contractor")
newName := "Hacked"
req := &requests.UpdateContractorRequest{Name: &newName}
_, err := service.UpdateContractor(contractor.ID, other.ID, req)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
}
func TestUpdateContractor_CrossResidence_Returns403(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -96,3 +512,171 @@ func TestUpdateContractor_RemoveResidence_Succeeds(t *testing.T) {
require.NoError(t, err, "should allow removing residence association")
require.NotNil(t, resp)
}
// === UpdateContractor — partial update multiple fields ===
func TestContractorService_UpdateContractor_PartialUpdate(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Original Name")
newName := "Updated Plumber"
newPhone := "555-9999"
newEmail := "new@plumber.com"
newCompany := "Best Plumbing"
newWebsite := "https://bestplumbing.com"
newNotes := "Great work"
newStreet := "456 Plumber Ave"
newCity := "Dallas"
newState := "TX"
newPostal := "75001"
rating := 5.0
isFav := true
req := &requests.UpdateContractorRequest{
Name: &newName,
Phone: &newPhone,
Email: &newEmail,
Company: &newCompany,
Website: &newWebsite,
Notes: &newNotes,
StreetAddress: &newStreet,
City: &newCity,
StateProvince: &newState,
PostalCode: &newPostal,
Rating: &rating,
IsFavorite: &isFav,
ResidenceID: &residence.ID,
}
resp, err := service.UpdateContractor(contractor.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated Plumber", resp.Name)
assert.Equal(t, "555-9999", resp.Phone)
assert.Equal(t, "new@plumber.com", resp.Email)
assert.True(t, resp.IsFavorite)
}
// === UpdateContractor — with specialties ===
func TestContractorService_UpdateContractor_WithSpecialties(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
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, "Test Contractor")
// Get specialty IDs from seeded data
var specialties []models.ContractorSpecialty
err := db.Find(&specialties).Error
require.NoError(t, err)
require.NotEmpty(t, specialties)
specialtyIDs := []uint{specialties[0].ID, specialties[1].ID}
req := &requests.UpdateContractorRequest{
SpecialtyIDs: specialtyIDs,
ResidenceID: &residence.ID,
}
resp, err := service.UpdateContractor(contractor.ID, user.ID, req)
require.NoError(t, err)
assert.NotNil(t, resp)
}
// === CreateContractor — with specialties ===
func TestContractorService_CreateContractor_WithSpecialties(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
var specialties []models.ContractorSpecialty
err := db.Find(&specialties).Error
require.NoError(t, err)
req := &requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Specialized Plumber",
SpecialtyIDs: []uint{specialties[0].ID},
}
resp, err := service.CreateContractor(req, user.ID)
require.NoError(t, err)
assert.Equal(t, "Specialized Plumber", resp.Name)
}
// === ListContractors — empty result ===
func TestContractorService_ListContractors_Empty(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// No residence, no contractors
resp, err := service.ListContractors(user.ID)
require.NoError(t, err)
assert.Empty(t, resp)
}
// === ListContractorsByResidence — empty result ===
func TestContractorService_ListContractorsByResidence_Empty(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Empty House")
resp, err := service.ListContractorsByResidence(residence.ID, user.ID)
require.NoError(t, err)
assert.Empty(t, resp)
}
// === Personal contractor access — creator has access, others don't ===
func TestContractorService_PersonalContractor_OnlyCreatorAccess(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewContractorService(contractorRepo, residenceRepo)
creator := testutil.CreateTestUser(t, db, "creator", "creator@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
// Create personal contractor (no residence)
req := &requests.CreateContractorRequest{
Name: "Personal Plumber",
}
resp, err := service.CreateContractor(req, creator.ID)
require.NoError(t, err)
// Creator can access
_, err = service.GetContractor(resp.ID, creator.ID)
require.NoError(t, err)
// Other user cannot
_, err = service.GetContractor(resp.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
}

View File

@@ -0,0 +1,764 @@
package services
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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 setupDocumentService(t *testing.T) (*DocumentService, *repositories.DocumentRepository, *repositories.ResidenceRepository) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
return service, documentRepo, residenceRepo
}
// === CreateDocument ===
func TestDocumentService_CreateDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateDocumentRequest{
ResidenceID: residence.ID,
Title: "Furnace Manual",
Description: "Installation manual for the furnace",
DocumentType: models.DocumentTypeManual,
FileURL: "https://example.com/manual.pdf",
FileName: "manual.pdf",
}
resp, err := service.CreateDocument(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Furnace Manual", resp.Title)
assert.Equal(t, models.DocumentTypeManual, resp.DocumentType)
}
func TestDocumentService_CreateDocument_DefaultType(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateDocumentRequest{
ResidenceID: residence.ID,
Title: "Some Document",
// DocumentType not set — should default to "general"
}
resp, err := service.CreateDocument(req, user.ID)
require.NoError(t, err)
assert.Equal(t, models.DocumentTypeGeneral, resp.DocumentType)
}
func TestDocumentService_CreateDocument_WithImages(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateDocumentRequest{
ResidenceID: residence.ID,
Title: "Receipt with photos",
ImageURLs: []string{"https://example.com/img1.jpg", "https://example.com/img2.jpg"},
}
resp, err := service.CreateDocument(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Receipt with photos", resp.Title)
}
func TestDocumentService_CreateDocument_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
req := &requests.CreateDocumentRequest{
ResidenceID: residence.ID,
Title: "Unauthorized Doc",
}
_, err := service.CreateDocument(req, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
// === GetDocument ===
func TestDocumentService_GetDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
resp, err := service.GetDocument(doc.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, doc.ID, resp.ID)
assert.Equal(t, "Test Doc", resp.Title)
}
func TestDocumentService_GetDocument_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.GetDocument(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_not_found")
}
func TestDocumentService_GetDocument_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
_, err := service.GetDocument(doc.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
// === UpdateDocument ===
func TestDocumentService_UpdateDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Original Title")
newTitle := "Updated Title"
newDesc := "Updated description"
req := &requests.UpdateDocumentRequest{
Title: &newTitle,
Description: &newDesc,
}
resp, err := service.UpdateDocument(doc.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated Title", resp.Title)
assert.Equal(t, "Updated description", resp.Description)
}
func TestDocumentService_UpdateDocument_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
newTitle := "Won't Work"
req := &requests.UpdateDocumentRequest{Title: &newTitle}
_, err := service.UpdateDocument(9999, user.ID, req)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_not_found")
}
func TestDocumentService_UpdateDocument_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
newTitle := "Hacked"
req := &requests.UpdateDocumentRequest{Title: &newTitle}
_, err := service.UpdateDocument(doc.ID, other.ID, req)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
func TestDocumentService_UpdateDocument_ChangeType(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "My Receipt")
newType := models.DocumentTypeWarranty
req := &requests.UpdateDocumentRequest{DocumentType: &newType}
resp, err := service.UpdateDocument(doc.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, models.DocumentTypeWarranty, resp.DocumentType)
}
// === DeleteDocument ===
func TestDocumentService_DeleteDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "To Delete")
err := service.DeleteDocument(doc.ID, user.ID)
require.NoError(t, err)
// Should not be found after deletion
_, err = service.GetDocument(doc.ID, user.ID)
assert.Error(t, err)
}
func TestDocumentService_DeleteDocument_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.DeleteDocument(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_not_found")
}
func TestDocumentService_DeleteDocument_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
err := service.DeleteDocument(doc.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
// === ListDocuments ===
func TestDocumentService_ListDocuments(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Doc 1")
testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Doc 2")
resp, err := service.ListDocuments(user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp, 2)
}
func TestDocumentService_ListDocuments_NoResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "loner", "loner@test.com", "Password123")
resp, err := service.ListDocuments(user.ID, nil)
require.NoError(t, err)
assert.Empty(t, resp)
}
func TestDocumentService_ListDocuments_FilterByResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
testutil.CreateTestDocument(t, db, residence1.ID, user.ID, "Doc A")
testutil.CreateTestDocument(t, db, residence2.ID, user.ID, "Doc B")
filter := &repositories.DocumentFilter{ResidenceID: &residence1.ID}
resp, err := service.ListDocuments(user.ID, filter)
require.NoError(t, err)
assert.Len(t, resp, 1)
assert.Equal(t, "Doc A", resp[0].Title)
}
func TestDocumentService_ListDocuments_FilterByResidence_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Owner House")
// other has their own residence so they have at least one
testutil.CreateTestResidence(t, db, other.ID, "Other House")
filter := &repositories.DocumentFilter{ResidenceID: &residence.ID}
_, err := service.ListDocuments(other.ID, filter)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
// === ListWarranties ===
func TestDocumentService_ListWarranties(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a warranty doc directly
warrantyDoc := &models.Document{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "HVAC Warranty",
DocumentType: "warranty",
FileURL: "https://example.com/warranty.pdf",
}
err := db.Create(warrantyDoc).Error
require.NoError(t, err)
// Create a general doc
testutil.CreateTestDocument(t, db, residence.ID, user.ID, "General Doc")
resp, err := service.ListWarranties(user.ID)
require.NoError(t, err)
assert.Len(t, resp, 1)
assert.Equal(t, "HVAC Warranty", resp[0].Title)
}
func TestDocumentService_ListWarranties_NoResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "loner", "loner@test.com", "Password123")
resp, err := service.ListWarranties(user.ID)
require.NoError(t, err)
assert.Empty(t, resp)
}
// === DeactivateDocument ===
func TestDocumentService_DeactivateDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "To Deactivate")
resp, err := service.DeactivateDocument(doc.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsActive)
}
func TestDocumentService_DeactivateDocument_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.DeactivateDocument(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_not_found")
}
func TestDocumentService_DeactivateDocument_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
_, err := service.DeactivateDocument(doc.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
// === UploadDocumentImage ===
func TestDocumentService_UploadDocumentImage(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
resp, err := service.UploadDocumentImage(doc.ID, user.ID, "https://example.com/photo.jpg", "Front view")
require.NoError(t, err)
assert.NotNil(t, resp)
}
func TestDocumentService_UploadDocumentImage_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.UploadDocumentImage(9999, user.ID, "https://example.com/photo.jpg", "")
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_not_found")
}
func TestDocumentService_UploadDocumentImage_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
_, err := service.UploadDocumentImage(doc.ID, other.ID, "https://example.com/photo.jpg", "")
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
// === DeleteDocumentImage ===
func TestDocumentService_DeleteDocumentImage(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
// Create an image
img := &models.DocumentImage{
DocumentID: doc.ID,
ImageURL: "https://example.com/photo.jpg",
}
err := db.Create(img).Error
require.NoError(t, err)
resp, err := service.DeleteDocumentImage(doc.ID, img.ID, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
}
func TestDocumentService_DeleteDocumentImage_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
_, err := service.DeleteDocumentImage(doc.ID, 9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_image_not_found")
}
func TestDocumentService_DeleteDocumentImage_WrongDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc1 := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Doc 1")
doc2 := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Doc 2")
// Create an image on doc1
img := &models.DocumentImage{
DocumentID: doc1.ID,
ImageURL: "https://example.com/photo.jpg",
}
err := db.Create(img).Error
require.NoError(t, err)
// Try to delete the image specifying doc2
_, err = service.DeleteDocumentImage(doc2.ID, img.ID, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_image_not_found")
}
func TestDocumentService_DeleteDocumentImage_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
img := &models.DocumentImage{
DocumentID: doc.ID,
ImageURL: "https://example.com/photo.jpg",
}
err := db.Create(img).Error
require.NoError(t, err)
_, err = service.DeleteDocumentImage(doc.ID, img.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
// === SharedUser access ===
func TestDocumentService_GetDocument_SharedUserHasAccess(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Shared Doc")
resp, err := service.GetDocument(doc.ID, shared.ID)
require.NoError(t, err)
assert.Equal(t, "Shared Doc", resp.Title)
}
// === ActivateDocument ===
func TestDocumentService_ActivateDocument(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "To Activate")
// Deactivate first
_, err := service.DeactivateDocument(doc.ID, user.ID)
require.NoError(t, err)
// Now activate
resp, err := service.ActivateDocument(doc.ID, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsActive)
}
func TestDocumentService_ActivateDocument_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
_, err := service.ActivateDocument(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.document_not_found")
}
func TestDocumentService_ActivateDocument_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Private Doc")
_, err := service.ActivateDocument(doc.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.document_access_denied")
}
// === CreateDocument — with empty image URL in array (should skip) ===
func TestDocumentService_CreateDocument_WithEmptyImageURL(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateDocumentRequest{
ResidenceID: residence.ID,
Title: "Doc with empty images",
ImageURLs: []string{"", "https://example.com/img.jpg", ""},
}
resp, err := service.CreateDocument(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
}
// === UpdateDocument — all optional fields ===
func TestDocumentService_UpdateDocument_AllFields(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Original")
newTitle := "Updated"
newDesc := "New description"
newType := models.DocumentTypeWarranty
newFileURL := "https://example.com/new.pdf"
newFileName := "new.pdf"
newMimeType := "application/pdf"
newVendor := "HVAC Corp"
newSerial := "SN12345"
newModel := "Model X"
size := int64(1024)
req := &requests.UpdateDocumentRequest{
Title: &newTitle,
Description: &newDesc,
DocumentType: &newType,
FileURL: &newFileURL,
FileName: &newFileName,
FileSize: &size,
MimeType: &newMimeType,
Vendor: &newVendor,
SerialNumber: &newSerial,
ModelNumber: &newModel,
}
resp, err := service.UpdateDocument(doc.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated", resp.Title)
assert.Equal(t, "New description", resp.Description)
assert.Equal(t, models.DocumentTypeWarranty, resp.DocumentType)
}
// === ListDocuments — filter by document type ===
func TestDocumentService_ListDocuments_FilterByType(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a warranty doc
warrantyDoc := &models.Document{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Warranty Doc",
DocumentType: models.DocumentTypeWarranty,
FileURL: "https://example.com/w.pdf",
}
err := db.Create(warrantyDoc).Error
require.NoError(t, err)
// Create a general doc
testutil.CreateTestDocument(t, db, residence.ID, user.ID, "General Doc")
filter := &repositories.DocumentFilter{DocumentType: string(models.DocumentTypeWarranty)}
resp, err := service.ListDocuments(user.ID, filter)
require.NoError(t, err)
assert.Len(t, resp, 1)
assert.Equal(t, "Warranty Doc", resp[0].Title)
}
// === Shared user can update/delete documents ===
func TestDocumentService_SharedUser_CanUpdate(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Shared Doc")
newTitle := "Updated by shared user"
req := &requests.UpdateDocumentRequest{Title: &newTitle}
resp, err := service.UpdateDocument(doc.ID, shared.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated by shared user", resp.Title)
}
func TestDocumentService_SharedUser_CanDelete(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewDocumentService(documentRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
doc := testutil.CreateTestDocument(t, db, residence.ID, owner.ID, "Shared Doc")
err := service.DeleteDocument(doc.ID, shared.ID)
require.NoError(t, err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -456,3 +456,631 @@ func TestCreateResidence_ProTier_AllowsMore(t *testing.T) {
func ptrTime(t time.Time) *time.Time {
return &t
}
// === GetMyResidences ===
func TestResidenceService_GetMyResidences(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", "Password123")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
resp, err := service.GetMyResidences(user.ID, time.Now())
require.NoError(t, err)
assert.Len(t, resp.Residences, 2)
}
func TestResidenceService_GetMyResidences_NoResidences(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, "loner", "loner@test.com", "Password123")
resp, err := service.GetMyResidences(user.ID, time.Now())
require.NoError(t, err)
assert.Empty(t, resp.Residences)
}
// === GetSummary ===
func TestResidenceService_GetSummary(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", "Password123")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
resp, err := service.GetSummary(user.ID, time.Now())
require.NoError(t, err)
assert.Equal(t, 2, resp.TotalResidences)
}
func TestResidenceService_GetSummary_NoResidences(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, "loner", "loner@test.com", "Password123")
resp, err := service.GetSummary(user.ID, time.Now())
require.NoError(t, err)
assert.Equal(t, 0, resp.TotalResidences)
}
// === GetShareCode ===
func TestResidenceService_GetShareCode_NoActiveCode(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
resp, err := service.GetShareCode(residence.ID, user.ID)
require.NoError(t, err)
assert.Nil(t, resp) // No active code
}
func TestResidenceService_GetShareCode_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", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
_, err := service.GetShareCode(residence.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
// === GenerateShareCode ===
func TestResidenceService_GenerateShareCode_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", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
_, err := service.GenerateShareCode(residence.ID, shared.ID, 24)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner")
}
func TestResidenceService_GenerateShareCode_DefaultExpiry(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Pass 0 hours — should default to 24
resp, err := service.GenerateShareCode(residence.ID, user.ID, 0)
require.NoError(t, err)
assert.NotEmpty(t, resp.ShareCode.Code)
}
// === GenerateSharePackage ===
func TestResidenceService_GenerateSharePackage(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
resp, err := service.GenerateSharePackage(residence.ID, user.ID, 48)
require.NoError(t, err)
assert.NotEmpty(t, resp.ShareCode)
assert.Equal(t, "Test House", resp.ResidenceName)
assert.Equal(t, "owner@test.com", resp.SharedBy)
}
func TestResidenceService_GenerateSharePackage_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", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
_, err := service.GenerateSharePackage(residence.ID, shared.ID, 24)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner")
}
// === JoinWithCode ===
func TestResidenceService_JoinWithCode_InvalidCode(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", "Password123")
_, err := service.JoinWithCode("BADCODE", user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.share_code_invalid")
}
// === RemoveUser ===
func TestResidenceService_RemoveUser_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", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
// shared user tries to remove other — should fail because shared is not owner
err := service.RemoveUser(residence.ID, other.ID, shared.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner")
}
// === GetResidenceUsers ===
func TestResidenceService_GetResidenceUsers_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", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
_, err := service.GetResidenceUsers(residence.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
// === GetResidenceTypes ===
func TestResidenceService_GetResidenceTypes(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
resp, err := service.GetResidenceTypes()
require.NoError(t, err)
// SeedLookupData creates 4 residence types
assert.Len(t, resp, 4)
}
// === UpdateResidence with home profile fields ===
func TestResidenceService_UpdateResidence_HomeProfileFields(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
hasPool := true
hasGarage := true
heatingType := "Forced Air"
req := &requests.UpdateResidenceRequest{
HasPool: &hasPool,
HasGarage: &hasGarage,
HeatingType: &heatingType,
}
resp, err := service.UpdateResidence(residence.ID, user.ID, req)
require.NoError(t, err)
assert.True(t, resp.Data.HasPool)
assert.True(t, resp.Data.HasGarage)
}
// === CreateResidence with home profile fields ===
func TestResidenceService_CreateResidence_HomeProfileFields(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", "Password123")
hasPool := true
hasSeptic := true
req := &requests.CreateResidenceRequest{
Name: "New House",
StreetAddress: "456 Oak St",
City: "Dallas",
StateProvince: "TX",
PostalCode: "75201",
HasPool: &hasPool,
HasSeptic: &hasSeptic,
}
resp, err := service.CreateResidence(req, user.ID)
require.NoError(t, err)
assert.True(t, resp.Data.HasPool)
assert.True(t, resp.Data.HasSeptic)
}
// === Shared user GetResidence ===
func TestResidenceService_GetResidence_SharedUser(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", "Password123")
shared := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, shared.ID)
resp, err := service.GetResidence(residence.ID, shared.ID, time.Now())
require.NoError(t, err)
assert.Equal(t, "Test House", resp.Name)
}
// === GetMyResidences with task repo (overdue counts + completion summaries) ===
func TestResidenceService_GetMyResidences_WithTaskRepo(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
taskRepo := repositories.NewTaskRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
service.SetTaskRepository(taskRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
resp, err := service.GetMyResidences(user.ID, time.Now())
require.NoError(t, err)
assert.Len(t, resp.Residences, 2)
}
// === GetResidence with task repo (completion summary) ===
func TestResidenceService_GetResidence_WithTaskRepo(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
taskRepo := repositories.NewTaskRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
service.SetTaskRepository(taskRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
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, "Test House", resp.Name)
}
// === GenerateShareCode with negative expiry defaults to 24 ===
func TestResidenceService_GenerateShareCode_NegativeExpiry(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
resp, err := service.GenerateShareCode(residence.ID, user.ID, -5)
require.NoError(t, err)
assert.NotEmpty(t, resp.ShareCode.Code)
}
// === GenerateSharePackage with default expiry ===
func TestResidenceService_GenerateSharePackage_DefaultExpiry(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Pass 0 hours — should default to 24
resp, err := service.GenerateSharePackage(residence.ID, user.ID, 0)
require.NoError(t, err)
assert.NotEmpty(t, resp.ShareCode)
assert.Equal(t, "Test House", resp.ResidenceName)
}
// === RemoveUser — trying to remove the owner by a different owner ID ===
func TestResidenceService_RemoveUser_OwnerViaResidenceOwnerID(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", "Password123")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, sharedUser.ID)
// Try removing the owner (by residence.OwnerID) — even though requestingUserID != userIDToRemove
// The second check (userIDToRemove == residence.OwnerID) should catch this
err := service.RemoveUser(residence.ID, owner.ID, owner.ID)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.cannot_remove_owner")
}
// === GenerateTasksReport ===
func TestResidenceService_GenerateTasksReport(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create some tasks
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
report, err := service.GenerateTasksReport(residence.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, residence.ID, report.ResidenceID)
assert.Equal(t, "Test House", report.ResidenceName)
assert.Equal(t, 2, report.TotalTasks)
}
func TestResidenceService_GenerateTasksReport_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", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
_, err := service.GenerateTasksReport(residence.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
func TestResidenceService_GenerateTasksReport_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, "owner", "owner@test.com", "Password123")
// Non-existent residence — user has no access
_, err := service.GenerateTasksReport(9999, user.ID)
assert.Error(t, err)
}
// === GetShareCode with active code ===
func TestResidenceService_GetShareCode_WithActiveCode(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Generate a share code first
_, err := service.GenerateShareCode(residence.ID, user.ID, 24)
require.NoError(t, err)
// Now get the active code
resp, err := service.GetShareCode(residence.ID, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.NotEmpty(t, resp.Code)
}
// === CreateResidence with all boolean fields ===
func TestResidenceService_CreateResidence_AllBooleanFields(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", "Password123")
hasPool := true
hasSprinkler := true
hasSeptic := true
hasFireplace := true
hasGarage := true
hasBasement := true
hasAttic := true
req := &requests.CreateResidenceRequest{
Name: "Full Feature House",
StreetAddress: "789 Full St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
HasPool: &hasPool,
HasSprinklerSystem: &hasSprinkler,
HasSeptic: &hasSeptic,
HasFireplace: &hasFireplace,
HasGarage: &hasGarage,
HasBasement: &hasBasement,
HasAttic: &hasAttic,
}
resp, err := service.CreateResidence(req, user.ID)
require.NoError(t, err)
assert.True(t, resp.Data.HasPool)
assert.True(t, resp.Data.HasSprinklerSystem)
assert.True(t, resp.Data.HasSeptic)
assert.True(t, resp.Data.HasFireplace)
assert.True(t, resp.Data.HasGarage)
assert.True(t, resp.Data.HasBasement)
assert.True(t, resp.Data.HasAttic)
}
// === UpdateResidence with all optional fields ===
func TestResidenceService_UpdateResidence_AllOptionalFields(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", "Password123")
residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name")
newStreet := "456 New St"
newApt := "Apt 2B"
newState := "CA"
newPostal := "90210"
newCountry := "Canada"
bedrooms := 4
bathrooms := decimal.NewFromFloat(3.0)
sqft := 3000
lotSize := decimal.NewFromFloat(0.5)
yearBuilt := 2020
newDesc := "Nice house"
isPrimary := false
hasPool := true
hasSprinkler := true
hasSeptic := false
hasFireplace := true
hasGarage := true
hasBasement := false
hasAttic := true
coolingType := "Central AC"
waterHeaterType := "Tankless"
roofType := "Shingle"
exteriorType := "Brick"
flooringPrimary := "Hardwood"
landscapingType := "Xeriscape"
req := &requests.UpdateResidenceRequest{
StreetAddress: &newStreet,
ApartmentUnit: &newApt,
StateProvince: &newState,
PostalCode: &newPostal,
Country: &newCountry,
Bedrooms: &bedrooms,
Bathrooms: &bathrooms,
SquareFootage: &sqft,
LotSize: &lotSize,
YearBuilt: &yearBuilt,
Description: &newDesc,
IsPrimary: &isPrimary,
HasPool: &hasPool,
HasSprinklerSystem: &hasSprinkler,
HasSeptic: &hasSeptic,
HasFireplace: &hasFireplace,
HasGarage: &hasGarage,
HasBasement: &hasBasement,
HasAttic: &hasAttic,
CoolingType: &coolingType,
WaterHeaterType: &waterHeaterType,
RoofType: &roofType,
ExteriorType: &exteriorType,
FlooringPrimary: &flooringPrimary,
LandscapingType: &landscapingType,
}
resp, err := service.UpdateResidence(residence.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "456 New St", resp.Data.StreetAddress)
assert.Equal(t, "CA", resp.Data.StateProvince)
assert.True(t, resp.Data.HasPool)
assert.True(t, resp.Data.HasFireplace)
assert.True(t, resp.Data.HasAttic)
}
// === ListResidences with no residences ===
func TestResidenceService_ListResidences_NoResidences(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, "loner", "loner@test.com", "Password123")
resp, err := service.ListResidences(user.ID)
require.NoError(t, err)
assert.Empty(t, resp)
}
// === getSummaryForUser returns empty summary ===
func TestResidenceService_getSummaryForUser_ReturnsEmpty(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
summary := service.getSummaryForUser(999)
assert.Equal(t, 0, summary.TotalResidences)
}

View File

@@ -162,3 +162,179 @@ func TestDelete_NonexistentFile(t *testing.T) {
t.Fatalf("Delete should not error for non-existent file: %v", err)
}
}
// === isAllowedType ===
func TestIsAllowedType(t *testing.T) {
cfg := &config.StorageConfig{
UploadDir: t.TempDir(),
BaseURL: "/uploads",
MaxFileSize: 10 * 1024 * 1024,
AllowedTypes: "image/jpeg,image/png,application/pdf",
}
svc := NewStorageServiceForTest(cfg)
if !svc.isAllowedType("image/jpeg") {
t.Fatal("image/jpeg should be allowed")
}
if !svc.isAllowedType("image/png") {
t.Fatal("image/png should be allowed")
}
if !svc.isAllowedType("application/pdf") {
t.Fatal("application/pdf should be allowed")
}
if svc.isAllowedType("text/html") {
t.Fatal("text/html should not be allowed")
}
if svc.isAllowedType("") {
t.Fatal("empty MIME should not be allowed")
}
}
// === mimeTypesCompatible ===
func TestMimeTypesCompatible(t *testing.T) {
cfg := &config.StorageConfig{
UploadDir: t.TempDir(),
BaseURL: "/uploads",
MaxFileSize: 10 * 1024 * 1024,
AllowedTypes: "image/jpeg",
}
svc := NewStorageServiceForTest(cfg)
// Same primary type
if !svc.mimeTypesCompatible("image/jpeg", "image/png") {
t.Fatal("image/* types should be compatible")
}
// Different primary types
if svc.mimeTypesCompatible("image/jpeg", "application/pdf") {
t.Fatal("image and application should not be compatible")
}
// Same exact types
if !svc.mimeTypesCompatible("application/pdf", "application/octet-stream") {
t.Fatal("application/* types should be compatible")
}
}
// === getExtensionFromMimeType ===
func TestGetExtensionFromMimeType(t *testing.T) {
cfg := &config.StorageConfig{
UploadDir: t.TempDir(),
BaseURL: "/uploads",
MaxFileSize: 10 * 1024 * 1024,
AllowedTypes: "image/jpeg",
}
svc := NewStorageServiceForTest(cfg)
tests := []struct {
mimeType string
expected string
}{
{"image/jpeg", ".jpg"},
{"image/png", ".png"},
{"image/gif", ".gif"},
{"image/webp", ".webp"},
{"application/pdf", ".pdf"},
{"text/html", ""},
{"unknown/type", ""},
}
for _, tt := range tests {
got := svc.getExtensionFromMimeType(tt.mimeType)
if got != tt.expected {
t.Fatalf("getExtensionFromMimeType(%q) = %q, want %q", tt.mimeType, got, tt.expected)
}
}
}
// === GetUploadDir ===
func TestGetUploadDir(t *testing.T) {
svc, tmpDir := setupTestStorage(t, false)
if svc.GetUploadDir() != tmpDir {
t.Fatalf("GetUploadDir() = %q, want %q", svc.GetUploadDir(), tmpDir)
}
}
// === SetEncryptionService ===
func TestSetEncryptionService(t *testing.T) {
svc, _ := setupTestStorage(t, false)
// Initially no encryption service
if svc.encryptionSvc != nil && svc.encryptionSvc.IsEnabled() {
t.Fatal("encryption should not be enabled initially in plain mode")
}
encSvc, err := NewEncryptionService(validTestKey())
if err != nil {
t.Fatal(err)
}
svc.SetEncryptionService(encSvc)
if svc.encryptionSvc == nil {
t.Fatal("encryption service should be set")
}
}
// === NewStorageServiceForTest ===
func TestNewStorageServiceForTest(t *testing.T) {
cfg := &config.StorageConfig{
UploadDir: "/tmp/test",
BaseURL: "/uploads",
MaxFileSize: 5 * 1024 * 1024,
AllowedTypes: "image/jpeg, image/png, application/pdf",
}
svc := NewStorageServiceForTest(cfg)
// Should have 3 allowed types (whitespace trimmed)
if !svc.isAllowedType("image/jpeg") {
t.Fatal("image/jpeg should be allowed")
}
if !svc.isAllowedType("image/png") {
t.Fatal("image/png should be allowed")
}
if !svc.isAllowedType("application/pdf") {
t.Fatal("application/pdf should be allowed")
}
}
// === Delete only plain file ===
func TestDelete_OnlyPlainFile(t *testing.T) {
svc, tmpDir := setupTestStorage(t, false)
dir := filepath.Join(tmpDir, "images")
os.WriteFile(filepath.Join(dir, "only-plain.jpg"), []byte("plain"), 0644)
err := svc.Delete("/uploads/images/only-plain.jpg")
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "only-plain.jpg")); !os.IsNotExist(err) {
t.Fatal("plain file should be deleted")
}
}
// === Delete only enc file ===
func TestDelete_OnlyEncFile(t *testing.T) {
svc, tmpDir := setupTestStorage(t, false)
dir := filepath.Join(tmpDir, "documents")
os.WriteFile(filepath.Join(dir, "secret.pdf.enc"), []byte("encrypted"), 0644)
err := svc.Delete("/uploads/documents/secret.pdf")
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "secret.pdf.enc")); !os.IsNotExist(err) {
t.Fatal("encrypted file should be deleted")
}
}

View File

@@ -181,6 +181,107 @@ func TestProcessGooglePurchase_ValidationFails_DoesNotUpgrade(t *testing.T) {
assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier")
}
// === GetSubscription ===
func TestSubscriptionService_GetSubscription(t *testing.T) {
db := testutil.SetupTestDB(t)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
svc := &SubscriptionService{
subscriptionRepo: subscriptionRepo,
residenceRepo: residenceRepo,
taskRepo: taskRepo,
contractorRepo: contractorRepo,
documentRepo: documentRepo,
}
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
resp, err := svc.GetSubscription(user.ID)
require.NoError(t, err)
assert.Equal(t, "free", resp.Tier)
assert.False(t, resp.IsPro)
}
func TestSubscriptionService_GetSubscription_ProUser(t *testing.T) {
db := testutil.SetupTestDB(t)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
svc := &SubscriptionService{
subscriptionRepo: subscriptionRepo,
residenceRepo: residenceRepo,
taskRepo: taskRepo,
contractorRepo: contractorRepo,
documentRepo: documentRepo,
}
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create a pro subscription
future := time.Now().UTC().Add(30 * 24 * time.Hour)
sub := &models.UserSubscription{
UserID: user.ID,
Tier: models.TierPro,
ExpiresAt: &future,
Platform: "ios",
}
err := db.Create(sub).Error
require.NoError(t, err)
resp, err := svc.GetSubscription(user.ID)
require.NoError(t, err)
assert.Equal(t, "pro", resp.Tier)
assert.True(t, resp.IsPro)
assert.True(t, resp.IsActive)
}
// === CancelSubscription ===
func TestSubscriptionService_CancelSubscription(t *testing.T) {
db := testutil.SetupTestDB(t)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
svc := &SubscriptionService{
subscriptionRepo: subscriptionRepo,
residenceRepo: residenceRepo,
taskRepo: taskRepo,
contractorRepo: contractorRepo,
documentRepo: documentRepo,
}
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create a pro subscription with auto_renew
future := time.Now().UTC().Add(30 * 24 * time.Hour)
sub := &models.UserSubscription{
UserID: user.ID,
Tier: models.TierPro,
ExpiresAt: &future,
AutoRenew: true,
}
err := db.Create(sub).Error
require.NoError(t, err)
resp, err := svc.CancelSubscription(user.ID)
require.NoError(t, err)
assert.False(t, resp.AutoRenew)
}
func TestIsAlreadyProFromOtherPlatform(t *testing.T) {
future := time.Now().UTC().Add(30 * 24 * time.Hour)

View File

@@ -231,3 +231,472 @@ func TestSuggestionService_MultipleConditionsAllMustMatch(t *testing.T) {
assert.InDelta(t, expectedScore, resp.Suggestions[0].RelevanceScore, 0.01)
assert.Len(t, resp.Suggestions[0].MatchReasons, 3)
}
// === Malformed conditions JSON treated as universal ===
func TestSuggestionService_MalformedConditions(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Test House")
// Create template with malformed JSON conditions
tmpl := &models.TaskTemplate{
Title: "Bad JSON Template",
IsActive: true,
Conditions: json.RawMessage(`{bad json`),
}
err := service.db.Create(tmpl).Error
require.NoError(t, err)
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Should be treated as universal
assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore)
}
// === Null conditions JSON treated as universal ===
func TestSuggestionService_NullConditions(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Test House")
tmpl := &models.TaskTemplate{
Title: "Null Conditions",
IsActive: true,
Conditions: json.RawMessage(`null`),
}
err := service.db.Create(tmpl).Error
require.NoError(t, err)
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore)
}
// === Template with property_type condition ===
func TestSuggestionService_PropertyTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
// Create a property type
propType := &models.ResidenceType{Name: "House"}
err := service.db.Create(propType).Error
require.NoError(t, err)
residence := &models.Residence{
OwnerID: user.ID,
Name: "My House",
IsActive: true,
IsPrimary: true,
PropertyTypeID: &propType.ID,
}
err = service.db.Create(residence).Error
require.NoError(t, err)
// Reload with PropertyType preloaded
err = service.db.Preload("PropertyType").First(residence, residence.ID).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "House Task", map[string]interface{}{
"property_type": "House",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "property_type:House")
}
// === CalculateProfileCompleteness with fully filled profile ===
func TestCalculateProfileCompleteness_FullProfile(t *testing.T) {
ht := "gas_furnace"
ct := "central_ac"
wht := "tank_gas"
rt := "asphalt_shingle"
et := "brick"
fp := "hardwood"
lt := "lawn"
residence := &models.Residence{
HeatingType: &ht,
CoolingType: &ct,
WaterHeaterType: &wht,
RoofType: &rt,
HasPool: true,
HasSprinklerSystem: true,
HasSeptic: true,
HasFireplace: true,
HasGarage: true,
HasBasement: true,
HasAttic: true,
ExteriorType: &et,
FlooringPrimary: &fp,
LandscapingType: &lt,
}
completeness := CalculateProfileCompleteness(residence)
assert.Equal(t, 1.0, completeness)
}
// === CalculateProfileCompleteness with empty profile ===
func TestCalculateProfileCompleteness_EmptyProfile(t *testing.T) {
residence := &models.Residence{}
completeness := CalculateProfileCompleteness(residence)
assert.Equal(t, 0.0, completeness)
}
// === Score capped at 1.0 ===
func TestSuggestionService_ScoreCappedAtOne(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
ht := "gas_furnace"
ct := "central_ac"
wht := "tank_gas"
rt := "asphalt_shingle"
et := "brick"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Full House",
IsActive: true,
IsPrimary: true,
HeatingType: &ht,
CoolingType: &ct,
WaterHeaterType: &wht,
RoofType: &rt,
ExteriorType: &et,
HasPool: true,
HasFireplace: true,
HasGarage: true,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
// Template that matches many fields — score should be capped at 1.0
createTemplateWithConditions(t, service, "Super Match", map[string]interface{}{
"heating_type": "gas_furnace",
"cooling_type": "central_ac",
"water_heater_type": "tank_gas",
"roof_type": "asphalt_shingle",
"exterior_type": "brick",
"has_pool": true,
"has_fireplace": true,
"has_garage": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.LessOrEqual(t, resp.Suggestions[0].RelevanceScore, 1.0)
}
// === Inactive templates are excluded ===
func TestSuggestionService_InactiveTemplateExcluded(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Test House")
// Create inactive template via raw SQL to bypass GORM default:true on is_active
err := service.db.Exec("INSERT INTO task_tasktemplate (title, is_active, conditions, created_at, updated_at) VALUES ('Inactive Task', false, '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").Error
require.NoError(t, err)
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
// === Template excluded when requires sprinkler but residence doesn't have it ===
func TestSuggestionService_ExcludedWhenSprinklerRequired(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "No Sprinkler House")
createTemplateWithConditions(t, service, "Sprinkler Maintenance", map[string]interface{}{
"has_sprinkler_system": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
// === All bool field exclusions ===
func TestSuggestionService_ExcludedWhenSepticRequired(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "City House")
createTemplateWithConditions(t, service, "Septic Pump", map[string]interface{}{
"has_septic": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
func TestSuggestionService_ExcludedWhenFireplaceRequired(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "No Fireplace")
createTemplateWithConditions(t, service, "Chimney Sweep", map[string]interface{}{
"has_fireplace": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
func TestSuggestionService_ExcludedWhenGarageRequired(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "No Garage")
createTemplateWithConditions(t, service, "Garage Door Service", map[string]interface{}{
"has_garage": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
func TestSuggestionService_ExcludedWhenBasementRequired(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Slab Home")
createTemplateWithConditions(t, service, "Basement Waterproofing", map[string]interface{}{
"has_basement": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
func TestSuggestionService_ExcludedWhenAtticRequired(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Flat Roof")
createTemplateWithConditions(t, service, "Attic Insulation", map[string]interface{}{
"has_attic": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
// === String field matches for all remaining types ===
func TestSuggestionService_CoolingTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
coolingType := "central_ac"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Cool House",
IsActive: true,
IsPrimary: true,
CoolingType: &coolingType,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "AC Filter", map[string]interface{}{
"cooling_type": "central_ac",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "cooling_type:central_ac")
}
func TestSuggestionService_WaterHeaterTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
wht := "tank_gas"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Hot Water House",
IsActive: true,
IsPrimary: true,
WaterHeaterType: &wht,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "Flush Water Heater", map[string]interface{}{
"water_heater_type": "tank_gas",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "water_heater_type:tank_gas")
}
func TestSuggestionService_ExteriorTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
et := "brick"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Brick House",
IsActive: true,
IsPrimary: true,
ExteriorType: &et,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "Pressure Wash Brick", map[string]interface{}{
"exterior_type": "brick",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "exterior_type:brick")
}
func TestSuggestionService_FlooringPrimaryMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
fp := "hardwood"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Hardwood House",
IsActive: true,
IsPrimary: true,
FlooringPrimary: &fp,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "Refinish Floors", map[string]interface{}{
"flooring_primary": "hardwood",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "flooring_primary:hardwood")
}
func TestSuggestionService_LandscapingTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
lt := "lawn"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Lawn House",
IsActive: true,
IsPrimary: true,
LandscapingType: &lt,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "Fertilize Lawn", map[string]interface{}{
"landscaping_type": "lawn",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "landscaping_type:lawn")
}
func TestSuggestionService_RoofTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
rt := "asphalt_shingle"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Shingle House",
IsActive: true,
IsPrimary: true,
RoofType: &rt,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
createTemplateWithConditions(t, service, "Inspect Roof", map[string]interface{}{
"roof_type": "asphalt_shingle",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "roof_type:asphalt_shingle")
}
// === Mismatch on string field — no score for that field ===
func TestSuggestionService_HeatingTypeMismatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "Password123")
heatingType := "electric_furnace"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Electric House",
IsActive: true,
IsPrimary: true,
HeatingType: &heatingType,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
// Template wants gas_furnace but residence has electric_furnace
createTemplateWithConditions(t, service, "Gas Furnace Service", map[string]interface{}{
"heating_type": "gas_furnace",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Should still be included but with partial_profile (no match, no exclude)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
}
// === templateConditions.isEmpty ===
func TestTemplateConditions_IsEmpty(t *testing.T) {
cond := &templateConditions{}
assert.True(t, cond.isEmpty())
ht := "gas"
cond2 := &templateConditions{HeatingType: &ht}
assert.False(t, cond2.isEmpty())
pool := true
cond3 := &templateConditions{HasPool: &pool}
assert.False(t, cond3.isEmpty())
pt := "House"
cond4 := &templateConditions{PropertyType: &pt}
assert.False(t, cond4.isEmpty())
}