Files
honeyDueAPI/internal/services/suggestion_service_test.go
Trey T bec880886b Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests)
- Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests)
- Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests)
- Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests)
- Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests)
- Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:30:09 -05:00

703 lines
22 KiB
Go

package services
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func setupSuggestionService(t *testing.T) *SuggestionService {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
return NewSuggestionService(db, residenceRepo)
}
func createTemplateWithConditions(t *testing.T, service *SuggestionService, title string, conditions interface{}) *models.TaskTemplate {
t.Helper()
var condJSON json.RawMessage
if conditions == nil {
condJSON = json.RawMessage(`{}`)
} else {
b, err := json.Marshal(conditions)
require.NoError(t, err)
condJSON = b
}
tmpl := &models.TaskTemplate{
Title: title,
IsActive: true,
Conditions: condJSON,
}
err := service.db.Create(tmpl).Error
require.NoError(t, err)
return tmpl
}
func TestSuggestionService_UniversalTemplate(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Test House")
// Create universal template (empty conditions)
createTemplateWithConditions(t, service, "Change Air Filters", nil)
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Equal(t, "Change Air Filters", resp.Suggestions[0].Template.Title)
assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "universal")
}
func TestSuggestionService_HeatingTypeMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
heatingType := "gas_furnace"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Test House",
IsActive: true,
IsPrimary: true,
HeatingType: &heatingType,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
// Template that requires gas_furnace
createTemplateWithConditions(t, service, "Furnace Filter Change", 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)
assert.Equal(t, stringMatchBonus, resp.Suggestions[0].RelevanceScore)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace")
}
func TestSuggestionService_ExcludedWhenPoolRequiredButFalse(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
// Residence with no pool (default false)
residence := testutil.CreateTestResidence(t, service.db, user.ID, "No Pool House")
// Template that requires pool
createTemplateWithConditions(t, service, "Clean Pool", map[string]interface{}{
"has_pool": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0) // Should be excluded
}
func TestSuggestionService_NilFieldIgnored(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
// Residence with no heating_type set (nil)
residence := testutil.CreateTestResidence(t, service.db, user.ID, "Basic House")
// Template that has a heating_type condition
createTemplateWithConditions(t, service, "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 be included (not excluded) but with low partial score
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
}
func TestSuggestionService_ProfileCompleteness(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
heatingType := "gas_furnace"
coolingType := "central_ac"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Partial House",
IsActive: true,
IsPrimary: true,
HeatingType: &heatingType,
CoolingType: &coolingType,
HasPool: true,
HasGarage: true,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
// Create at least one template so we get a response
createTemplateWithConditions(t, service, "Universal Task", nil)
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
// 4 fields filled out of 14
expectedCompleteness := 4.0 / 14.0
assert.InDelta(t, expectedCompleteness, resp.ProfileCompleteness, 0.01)
}
func TestSuggestionService_SortedByScoreDescending(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
heatingType := "gas_furnace"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Full House",
IsActive: true,
IsPrimary: true,
HeatingType: &heatingType,
HasPool: true,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
// Universal template - low score
createTemplateWithConditions(t, service, "Universal Task", nil)
// Template with matching heating_type - medium score
createTemplateWithConditions(t, service, "Furnace Service", map[string]interface{}{
"heating_type": "gas_furnace",
})
// Template with matching pool - high score
createTemplateWithConditions(t, service, "Pool Clean", map[string]interface{}{
"has_pool": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 3)
// Verify sorted by score descending
for i := 0; i < len(resp.Suggestions)-1; i++ {
assert.GreaterOrEqual(t, resp.Suggestions[i].RelevanceScore, resp.Suggestions[i+1].RelevanceScore)
}
}
func TestSuggestionService_AccessDenied(t *testing.T) {
service := setupSuggestionService(t)
owner := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
stranger := testutil.CreateTestUser(t, service.db, "stranger", "stranger@test.com", "password")
residence := testutil.CreateTestResidence(t, service.db, owner.ID, "Private House")
_, err := service.GetSuggestions(residence.ID, stranger.ID)
require.Error(t, err)
testutil.AssertAppErrorCode(t, err, 403)
}
func TestSuggestionService_MultipleConditionsAllMustMatch(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
heatingType := "gas_furnace"
residence := &models.Residence{
OwnerID: user.ID,
Name: "Full House",
IsActive: true,
IsPrimary: true,
HeatingType: &heatingType,
HasPool: true,
HasFireplace: true,
}
err := service.db.Create(residence).Error
require.NoError(t, err)
// Template requiring both pool AND fireplace AND heating_type
createTemplateWithConditions(t, service, "Pool & Fireplace Combo", map[string]interface{}{
"has_pool": true,
"has_fireplace": true,
"heating_type": "gas_furnace",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// All three conditions matched
expectedScore := boolMatchBonus + boolMatchBonus + stringMatchBonus // 0.3 + 0.3 + 0.25 = 0.85
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())
}