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.
415 lines
12 KiB
Go
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")
|
|
}
|