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: <, } 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: <, } 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()) }