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) }