Some checks failed
Clients that send users through a multi-task onboarding step no longer loop N POST /api/tasks/ calls and no longer create "orphan" tasks with no reference to the TaskTemplate they came from. Task model - New task_template_id column + GORM FK (migration 000016) - CreateTaskRequest.template_id, TaskResponse.template_id - task_service.CreateTask persists the backlink Bulk endpoint - POST /api/tasks/bulk/ — 1-50 tasks in a single transaction, returns every created row + TotalSummary. Single residence access check, per-entry residence_id is overridden with batch value - task_handler.BulkCreateTasks + task_service.BulkCreateTasks using db.Transaction; task_repo.CreateTx + FindByIDTx helpers Climate-region scoring - templateConditions gains ClimateRegionID; suggestion_service scores residence.PostalCode -> ZipToState -> GetClimateRegionIDByState against the template's conditions JSON (no penalty on mismatch / unknown ZIP) - regionMatchBonus 0.35, totalProfileFields 14 -> 15 - Standalone GET /api/tasks/templates/by-region/ removed; legacy task_tasktemplate_regions many-to-many dropped (migration 000017). Region affinity now lives entirely in the template's conditions JSON Tests - +11 cases across task_service_test, task_handler_test, suggestion_ service_test: template_id persistence, bulk rollback + cap + auth, region match / mismatch / no-ZIP / unknown-ZIP / stacks-with-others Docs - docs/openapi.yaml: /tasks/bulk/ + BulkCreateTasks schemas, template_id on TaskResponse + CreateTaskRequest, /templates/by-region/ removed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
840 lines
27 KiB
Go
840 lines
27 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 15 (home-profile fields + ZIP/region)
|
|
expectedCompleteness := 4.0 / float64(totalProfileFields)
|
|
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: <,
|
|
PostalCode: "10001", // NY → zone 5 — counts as the 15th field
|
|
}
|
|
|
|
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())
|
|
|
|
var regionID uint = 5
|
|
cond5 := &templateConditions{ClimateRegionID: ®ionID}
|
|
assert.False(t, cond5.isEmpty())
|
|
}
|
|
|
|
// === Climate region condition (15th field) ===
|
|
|
|
func TestSuggestionService_ClimateRegionMatch(t *testing.T) {
|
|
service := setupSuggestionService(t)
|
|
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
|
|
|
|
// NY ZIP 10001 → prefix 100 → NY → zone 5 (Cold)
|
|
residence := &models.Residence{
|
|
OwnerID: user.ID,
|
|
Name: "NYC House",
|
|
IsActive: true,
|
|
IsPrimary: true,
|
|
PostalCode: "10001",
|
|
}
|
|
require.NoError(t, service.db.Create(residence).Error)
|
|
|
|
// Template tagged for zone 5 (Cold)
|
|
createTemplateWithConditions(t, service, "Winterize Sprinkler", map[string]interface{}{
|
|
"climate_region_id": 5,
|
|
})
|
|
|
|
resp, err := service.GetSuggestions(residence.ID, user.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Suggestions, 1)
|
|
assert.InDelta(t, climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001)
|
|
assert.Contains(t, resp.Suggestions[0].MatchReasons, "climate_region")
|
|
}
|
|
|
|
func TestSuggestionService_ClimateRegionMismatch(t *testing.T) {
|
|
service := setupSuggestionService(t)
|
|
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
|
|
|
|
// FL ZIP 33101 → FL → zone 1 (Hot-Humid)
|
|
residence := &models.Residence{
|
|
OwnerID: user.ID,
|
|
Name: "Miami House",
|
|
IsActive: true,
|
|
IsPrimary: true,
|
|
PostalCode: "33101",
|
|
}
|
|
require.NoError(t, service.db.Create(residence).Error)
|
|
|
|
// Template tagged for zone 6 (Very Cold) — no match
|
|
createTemplateWithConditions(t, service, "Snowblower Service", map[string]interface{}{
|
|
"climate_region_id": 6,
|
|
})
|
|
|
|
resp, err := service.GetSuggestions(residence.ID, user.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Suggestions, 1) // Still included — mismatch doesn't exclude
|
|
assert.InDelta(t, baseUniversalScore*0.5, resp.Suggestions[0].RelevanceScore, 0.001)
|
|
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
|
|
}
|
|
|
|
func TestSuggestionService_ClimateRegionIgnoredWhenNoZip(t *testing.T) {
|
|
service := setupSuggestionService(t)
|
|
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
|
|
|
|
// Explicitly blank ZIP — testutil.CreateTestResidence seeds "12345" by
|
|
// default, which maps to NY/zone 5, so we can't reuse the helper here.
|
|
residence := &models.Residence{
|
|
OwnerID: user.ID,
|
|
Name: "No ZIP House",
|
|
IsActive: true,
|
|
IsPrimary: true,
|
|
PostalCode: "",
|
|
}
|
|
require.NoError(t, service.db.Create(residence).Error)
|
|
|
|
createTemplateWithConditions(t, service, "Zone-Specific Task", map[string]interface{}{
|
|
"climate_region_id": 5,
|
|
})
|
|
|
|
resp, err := service.GetSuggestions(residence.ID, user.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Suggestions, 1) // Still included, just no bonus
|
|
assert.InDelta(t, baseUniversalScore*0.5, resp.Suggestions[0].RelevanceScore, 0.001)
|
|
}
|
|
|
|
func TestSuggestionService_ClimateRegionUnknownZip(t *testing.T) {
|
|
service := setupSuggestionService(t)
|
|
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
|
|
|
|
residence := &models.Residence{
|
|
OwnerID: user.ID,
|
|
Name: "Garbage ZIP House",
|
|
IsActive: true,
|
|
IsPrimary: true,
|
|
PostalCode: "XYZ12", // not a real US ZIP
|
|
}
|
|
require.NoError(t, service.db.Create(residence).Error)
|
|
|
|
createTemplateWithConditions(t, service, "Zone-Specific Task", map[string]interface{}{
|
|
"climate_region_id": 5,
|
|
})
|
|
|
|
resp, err := service.GetSuggestions(residence.ID, user.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Suggestions, 1)
|
|
// Unknown ZIP → 0 region → no match, but no crash
|
|
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
|
|
}
|
|
|
|
func TestSuggestionService_ClimateRegionStacksWithOtherConditions(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: "NY Gas House",
|
|
IsActive: true,
|
|
IsPrimary: true,
|
|
PostalCode: "10001", // NY → zone 5
|
|
HeatingType: &heatingType,
|
|
}
|
|
require.NoError(t, service.db.Create(residence).Error)
|
|
|
|
createTemplateWithConditions(t, service, "Winterize Gas Furnace", map[string]interface{}{
|
|
"heating_type": "gas_furnace",
|
|
"climate_region_id": 5,
|
|
})
|
|
|
|
resp, err := service.GetSuggestions(residence.ID, user.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Suggestions, 1)
|
|
// Both bonuses should apply: stringMatchBonus + climateRegionBonus
|
|
assert.InDelta(t, stringMatchBonus+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001)
|
|
assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace")
|
|
assert.Contains(t, resp.Suggestions[0].MatchReasons, "climate_region")
|
|
}
|