Smart onboarding: residence home profile + suggestion engine

14 new optional residence fields (heating, cooling, water heater, roof,
pool, sprinkler, septic, fireplace, garage, basement, attic, exterior,
flooring, landscaping) with JSONB conditions on templates.

Suggestion engine scores templates against home profile: string match
+0.25, bool +0.3, property type +0.15, universal base 0.3. Graceful
degradation from minimal to full profile info.

GET /api/tasks/suggestions/?residence_id=X returns ranked templates.
54 template conditions across 44 templates in seed data.
8 suggestion service tests.
This commit is contained in:
Trey T
2026-03-30 09:02:03 -05:00
parent 4c9a818bd9
commit cb7080c460
16 changed files with 1347 additions and 32 deletions

View File

@@ -0,0 +1,233 @@
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)
}