Files
honeyDueAPI/internal/testutil/testutil.go
Trey T cb7080c460 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.
2026-03-30 09:02:03 -05:00

415 lines
12 KiB
Go

package testutil
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/validator"
)
var i18nOnce sync.Once
// SetupTestDB creates an in-memory SQLite database for testing
func SetupTestDB(t *testing.T) *gorm.DB {
// Initialize i18n once for all tests
i18nOnce.Do(func() {
i18n.Init()
})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
// Migrate all models
err = db.AutoMigrate(
&models.User{},
&models.UserProfile{},
&models.AuthToken{},
&models.ConfirmationCode{},
&models.PasswordResetCode{},
&models.AdminUser{},
&models.Residence{},
&models.ResidenceType{},
&models.ResidenceShareCode{},
&models.Task{},
&models.TaskCategory{},
&models.TaskPriority{},
&models.TaskFrequency{},
&models.TaskCompletion{},
&models.TaskCompletionImage{},
&models.Contractor{},
&models.ContractorSpecialty{},
&models.Document{},
&models.DocumentImage{},
&models.Notification{},
&models.NotificationPreference{},
&models.APNSDevice{},
&models.GCMDevice{},
&models.AppleSocialAuth{},
&models.GoogleSocialAuth{},
&models.TaskReminderLog{},
&models.UserSubscription{},
&models.SubscriptionSettings{},
&models.TierLimits{},
&models.FeatureBenefit{},
&models.UpgradeTrigger{},
&models.Promotion{},
&models.AuditLog{},
&models.TaskTemplate{},
&models.ClimateRegion{},
)
require.NoError(t, err)
return db
}
// SetupTestRouter creates a test Echo router with the custom error handler.
// Uses apperrors.HTTPErrorHandler which is the base error handler shared with
// production (router.customHTTPErrorHandler). Both handle AppError, ValidationErrors,
// and echo.HTTPError identically. Production additionally maps legacy service sentinel
// errors (e.g., services.ErrTaskNotFound) which are being migrated to AppError types.
// Tests exercise handlers that return AppError, so this handler covers all test scenarios.
func SetupTestRouter() *echo.Echo {
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
return e
}
// MakeRequest makes a test HTTP request and returns the response.
// Errors from JSON marshaling and HTTP request construction are checked and
// will panic if they occur, since these indicate programming errors in tests.
// Prefer MakeRequestT for new tests, which uses t.Fatal for better reporting.
func MakeRequest(router *echo.Echo, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var reqBody *bytes.Buffer
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("testutil.MakeRequest: failed to marshal request body: %v", err))
}
reqBody = bytes.NewBuffer(jsonBody)
} else {
reqBody = bytes.NewBuffer(nil)
}
req, err := http.NewRequest(method, path, reqBody)
if err != nil {
panic(fmt.Sprintf("testutil.MakeRequest: failed to create HTTP request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Token "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// MakeRequestT is like MakeRequest but accepts a *testing.T for proper test
// failure reporting. Prefer this over MakeRequest in new tests.
func MakeRequestT(t *testing.T, router *echo.Echo, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
t.Helper()
var reqBody *bytes.Buffer
if body != nil {
jsonBody, err := json.Marshal(body)
require.NoError(t, err, "failed to marshal request body")
reqBody = bytes.NewBuffer(jsonBody)
} else {
reqBody = bytes.NewBuffer(nil)
}
req, err := http.NewRequest(method, path, reqBody)
require.NoError(t, err, "failed to create HTTP request")
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Token "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// ParseJSON parses JSON response body into a map
func ParseJSON(t *testing.T, body []byte) map[string]interface{} {
var result map[string]interface{}
err := json.Unmarshal(body, &result)
require.NoError(t, err)
return result
}
// ParseJSONArray parses JSON response body into an array
func ParseJSONArray(t *testing.T, body []byte) []map[string]interface{} {
var result []map[string]interface{}
err := json.Unmarshal(body, &result)
require.NoError(t, err)
return result
}
// CreateTestUser creates a test user in the database
func CreateTestUser(t *testing.T, db *gorm.DB, username, email, password string) *models.User {
user := &models.User{
Username: username,
Email: email,
IsActive: true,
}
err := user.SetPassword(password)
require.NoError(t, err)
err = db.Create(user).Error
require.NoError(t, err)
return user
}
// CreateTestToken creates an auth token for a user
func CreateTestToken(t *testing.T, db *gorm.DB, userID uint) *models.AuthToken {
token, err := models.GetOrCreateToken(db, userID)
require.NoError(t, err)
return token
}
// CreateTestResidenceType creates a test residence type
func CreateTestResidenceType(t *testing.T, db *gorm.DB, name string) *models.ResidenceType {
rt := &models.ResidenceType{Name: name}
err := db.Create(rt).Error
require.NoError(t, err)
return rt
}
// CreateTestResidence creates a test residence
func CreateTestResidence(t *testing.T, db *gorm.DB, ownerID uint, name string) *models.Residence {
residence := &models.Residence{
OwnerID: ownerID,
Name: name,
StreetAddress: "123 Test St",
City: "Test City",
StateProvince: "TS",
PostalCode: "12345",
Country: "USA",
IsActive: true,
IsPrimary: true,
}
err := db.Create(residence).Error
require.NoError(t, err)
return residence
}
// CreateTestTaskCategory creates a test task category
func CreateTestTaskCategory(t *testing.T, db *gorm.DB, name string) *models.TaskCategory {
cat := &models.TaskCategory{
Name: name,
DisplayOrder: 1,
}
err := db.Create(cat).Error
require.NoError(t, err)
return cat
}
// CreateTestTaskPriority creates a test task priority
func CreateTestTaskPriority(t *testing.T, db *gorm.DB, name string, level int) *models.TaskPriority {
priority := &models.TaskPriority{
Name: name,
Level: level,
DisplayOrder: level,
}
err := db.Create(priority).Error
require.NoError(t, err)
return priority
}
// CreateTestTaskFrequency creates a test task frequency
func CreateTestTaskFrequency(t *testing.T, db *gorm.DB, name string, days *int) *models.TaskFrequency {
freq := &models.TaskFrequency{
Name: name,
Days: days,
DisplayOrder: 1,
}
err := db.Create(freq).Error
require.NoError(t, err)
return freq
}
// CreateTestTask creates a test task
func CreateTestTask(t *testing.T, db *gorm.DB, residenceID, createdByID uint, title string) *models.Task {
task := &models.Task{
ResidenceID: residenceID,
CreatedByID: createdByID,
Title: title,
IsCancelled: false,
IsArchived: false,
Version: 1,
}
err := db.Create(task).Error
require.NoError(t, err)
return task
}
// SeedLookupData seeds all lookup tables with test data.
// All GORM create operations are checked for errors to prevent silent failures
// that could cause misleading test results.
func SeedLookupData(t *testing.T, db *gorm.DB) {
t.Helper()
// Residence types
residenceTypes := []models.ResidenceType{
{Name: "House"},
{Name: "Apartment"},
{Name: "Condo"},
{Name: "Townhouse"},
}
for i := range residenceTypes {
err := db.Create(&residenceTypes[i]).Error
require.NoError(t, err, "failed to seed residence type: %s", residenceTypes[i].Name)
}
// Task categories
categories := []models.TaskCategory{
{Name: "Plumbing", DisplayOrder: 1},
{Name: "Electrical", DisplayOrder: 2},
{Name: "HVAC", DisplayOrder: 3},
{Name: "General", DisplayOrder: 99},
}
for i := range categories {
err := db.Create(&categories[i]).Error
require.NoError(t, err, "failed to seed task category: %s", categories[i].Name)
}
// Task priorities
priorities := []models.TaskPriority{
{Name: "Low", Level: 1, DisplayOrder: 1},
{Name: "Medium", Level: 2, DisplayOrder: 2},
{Name: "High", Level: 3, DisplayOrder: 3},
{Name: "Urgent", Level: 4, DisplayOrder: 4},
}
for i := range priorities {
err := db.Create(&priorities[i]).Error
require.NoError(t, err, "failed to seed task priority: %s", priorities[i].Name)
}
// Task frequencies
days7 := 7
days30 := 30
frequencies := []models.TaskFrequency{
{Name: "Once", Days: nil, DisplayOrder: 1},
{Name: "Weekly", Days: &days7, DisplayOrder: 2},
{Name: "Monthly", Days: &days30, DisplayOrder: 3},
}
for i := range frequencies {
err := db.Create(&frequencies[i]).Error
require.NoError(t, err, "failed to seed task frequency: %s", frequencies[i].Name)
}
// Contractor specialties
specialties := []models.ContractorSpecialty{
{Name: "Plumber"},
{Name: "Electrician"},
{Name: "HVAC Technician"},
{Name: "Handyman"},
}
for i := range specialties {
err := db.Create(&specialties[i]).Error
require.NoError(t, err, "failed to seed contractor specialty: %s", specialties[i].Name)
}
}
// AssertJSONField asserts that a JSON field has the expected value
func AssertJSONField(t *testing.T, data map[string]interface{}, field string, expected interface{}) {
actual, ok := data[field]
require.True(t, ok, "field %s not found in response", field)
require.Equal(t, expected, actual, "field %s has unexpected value", field)
}
// AssertJSONFieldExists asserts that a JSON field exists
func AssertJSONFieldExists(t *testing.T, data map[string]interface{}, field string) {
_, ok := data[field]
require.True(t, ok, "field %s not found in response", field)
}
// AssertStatusCode asserts the HTTP status code
func AssertStatusCode(t *testing.T, w *httptest.ResponseRecorder, expected int) {
require.Equal(t, expected, w.Code, "unexpected status code: %s", w.Body.String())
}
// MockAuthMiddleware creates middleware that sets a test user in context
func MockAuthMiddleware(user *models.User) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("auth_user", user)
c.Set("auth_token", "test-token")
return next(c)
}
}
}
// CreateTestContractor creates a test contractor
func CreateTestContractor(t *testing.T, db *gorm.DB, residenceID, createdByID uint, name string) *models.Contractor {
contractor := &models.Contractor{
ResidenceID: &residenceID,
CreatedByID: createdByID,
Name: name,
IsActive: true,
}
err := db.Create(contractor).Error
require.NoError(t, err)
return contractor
}
// CreateTestContractorSpecialty creates a test contractor specialty
func CreateTestContractorSpecialty(t *testing.T, db *gorm.DB, name string) *models.ContractorSpecialty {
specialty := &models.ContractorSpecialty{Name: name}
err := db.Create(specialty).Error
require.NoError(t, err)
return specialty
}
// CreateTestDocument creates a test document
func CreateTestDocument(t *testing.T, db *gorm.DB, residenceID, createdByID uint, title string) *models.Document {
doc := &models.Document{
ResidenceID: residenceID,
CreatedByID: createdByID,
Title: title,
DocumentType: "general",
FileURL: "https://example.com/doc.pdf",
}
err := db.Create(doc).Error
require.NoError(t, err)
return doc
}
// AssertAppError asserts that an error is an AppError with a specific status code and message key
func AssertAppError(t *testing.T, err error, expectedCode int, expectedMessageKey string) {
require.Error(t, err, "expected an error")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr, "expected an AppError")
require.Equal(t, expectedCode, appErr.Code, "unexpected status code")
require.Equal(t, expectedMessageKey, appErr.MessageKey, "unexpected message key")
}
// AssertAppErrorCode asserts that an error is an AppError with a specific status code
func AssertAppErrorCode(t *testing.T, err error, expectedCode int) {
require.Error(t, err, "expected an error")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr, "expected an AppError")
require.Equal(t, expectedCode, appErr.Code, "unexpected status code")
}