Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls
Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
middleware expiry (5), validator (new)
413 lines
12 KiB
Go
413 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{},
|
|
)
|
|
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")
|
|
}
|