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:
233
internal/services/suggestion_service_test.go
Normal file
233
internal/services/suggestion_service_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user