e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
684 lines
27 KiB
Go
684 lines
27 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"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 setupContractorService(t *testing.T) (*ContractorService, *repositories.ContractorRepository, *repositories.ResidenceRepository) {
|
|
db := testutil.SetupTestDB(t)
|
|
testutil.SeedLookupData(t, db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
service := NewContractorService(contractorRepo, residenceRepo)
|
|
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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), contractor.ID, user.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Should not be found after deletion
|
|
_, err = service.GetContractor(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), contractor.ID, user.ID)
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.IsFavorite)
|
|
|
|
// Toggle to favorite
|
|
resp, err = service.ToggleFavorite(context.Background(), contractor.ID, user.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, resp.IsFavorite)
|
|
|
|
// Toggle back
|
|
resp, err = service.ToggleFavorite(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background())
|
|
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(context.Background(), 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(context.Background(), 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)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
service := NewContractorService(contractorRepo, residenceRepo)
|
|
|
|
// Create two users: owner and attacker
|
|
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
|
attacker := testutil.CreateTestUser(t, db, "attacker", "attacker@test.com", "password")
|
|
|
|
// Owner creates a residence
|
|
ownerResidence := testutil.CreateTestResidence(t, db, owner.ID, "Owner House")
|
|
|
|
// Attacker creates a residence and a contractor in their residence
|
|
attackerResidence := testutil.CreateTestResidence(t, db, attacker.ID, "Attacker House")
|
|
contractor := testutil.CreateTestContractor(t, db, attackerResidence.ID, attacker.ID, "My Contractor")
|
|
|
|
// Attacker tries to reassign their contractor to the owner's residence
|
|
// This should be denied because the attacker does not have access to the owner's residence
|
|
newResidenceID := ownerResidence.ID
|
|
req := &requests.UpdateContractorRequest{
|
|
ResidenceID: &newResidenceID,
|
|
}
|
|
|
|
_, err := service.UpdateContractor(context.Background(), contractor.ID, attacker.ID, req)
|
|
require.Error(t, err, "should not allow reassigning contractor to a residence the user has no access to")
|
|
testutil.AssertAppErrorCode(t, err, http.StatusForbidden)
|
|
}
|
|
|
|
func TestUpdateContractor_SameResidence_Succeeds(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", "password")
|
|
residence1 := testutil.CreateTestResidence(t, db, owner.ID, "House 1")
|
|
residence2 := testutil.CreateTestResidence(t, db, owner.ID, "House 2")
|
|
contractor := testutil.CreateTestContractor(t, db, residence1.ID, owner.ID, "My Contractor")
|
|
|
|
// Owner reassigns contractor to their other residence - should succeed
|
|
newResidenceID := residence2.ID
|
|
newName := "Updated Contractor"
|
|
req := &requests.UpdateContractorRequest{
|
|
Name: &newName,
|
|
ResidenceID: &newResidenceID,
|
|
}
|
|
|
|
resp, err := service.UpdateContractor(context.Background(), contractor.ID, owner.ID, req)
|
|
require.NoError(t, err, "should allow reassigning contractor to a residence the user owns")
|
|
require.NotNil(t, resp)
|
|
require.Equal(t, "Updated Contractor", resp.Name)
|
|
}
|
|
|
|
func TestUpdateContractor_RemoveResidence_Succeeds(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", "password")
|
|
residence := testutil.CreateTestResidence(t, db, owner.ID, "My House")
|
|
contractor := testutil.CreateTestContractor(t, db, residence.ID, owner.ID, "My Contractor")
|
|
|
|
// Setting ResidenceID to nil should remove the residence association (make it personal)
|
|
req := &requests.UpdateContractorRequest{
|
|
ResidenceID: nil,
|
|
}
|
|
|
|
resp, err := service.UpdateContractor(context.Background(), contractor.ID, owner.ID, req)
|
|
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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), req, creator.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Creator can access
|
|
_, err = service.GetContractor(context.Background(), resp.ID, creator.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Other user cannot
|
|
_, err = service.GetContractor(context.Background(), resp.ID, other.ID)
|
|
testutil.AssertAppError(t, err, http.StatusForbidden, "error.contractor_access_denied")
|
|
}
|