fix: improve test infrastructure and add ID validation

- Add TestMain to set test env vars and testdb.TruncateTables for test
  isolation
- Add chi routing context to test helpers for path parameter extraction
- Fix SQL error handling to use errors.Is() instead of ==
- Add validators package with ID validation
- Fix admin test middleware chain (RequireAdmin wrapper)
- Update test user inserts to include phone and date_of_birth fields
- Update service delete test to check soft-delete (is_active=false)
- Update holiday hours test to use new schema (weekday, is_open)
- Add phone number validation tests for UK mobile numbers
This commit is contained in:
2026-02-23 00:59:32 +00:00
parent 355e8a26c1
commit df3439bd70
30 changed files with 1081 additions and 360 deletions
+14 -10
View File
@@ -3,9 +3,11 @@ package services
import (
"crussell/auth"
"crussell/db"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
@@ -53,8 +55,8 @@ type CreateServiceRequest struct {
// ToggleServiceHandler handles toggling a service's active status
func ToggleService(w http.ResponseWriter, r *http.Request) {
serviceID := chi.URLParam(r, "id")
if serviceID == "" {
http.Error(w, "Service ID is required", http.StatusBadRequest)
if serviceID == "" || !validators.IsValidID(serviceID) {
http.Error(w, "Service not found", http.StatusNotFound)
return
}
@@ -184,12 +186,14 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
// DeleteServiceHandler handles soft deleting a service (setting is_active to false)
func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
serviceID := chi.URLParam(r, "id")
if serviceID == "" {
http.Error(w, "Service ID is required", http.StatusBadRequest)
if serviceID == "" || !validators.IsValidID(serviceID) {
http.Error(w, "Service not found", http.StatusNotFound)
return
}
query := "DELETE FROM services WHERE id = $1"
// Use soft delete - set is_active to FALSE instead of hard delete
// This preserves referential integrity with booking_services
query := "UPDATE services SET is_active = FALSE WHERE id = $1"
result, err := db.DB.Exec(r.Context(), query, serviceID)
if err != nil {
http.Error(w, "Failed to delete service: "+err.Error(), http.StatusInternalServerError)
@@ -353,7 +357,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
`SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`,
userID, service.ID).Scan(&lastTime)
if err == sql.ErrNoRows || err == pgx.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
// No patch test record
status := "required"
service.PatchTestStatus = &status
@@ -409,15 +413,15 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
// Used by admin booking flows when booking on behalf of a user
func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
if userID == "" {
http.Error(w, "User ID required", http.StatusBadRequest)
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Get user's date of birth
var dob time.Time
err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
@@ -481,7 +485,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
`SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`,
userID, service.ID).Scan(&lastTime)
if err == sql.ErrNoRows || err == pgx.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
// No patch test record - gray out
status := "required"
service.PatchTestStatus = &status
+52 -13
View File
@@ -15,6 +15,8 @@ import (
"crussell/handlers/user"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
)
func setupTestDB(t *testing.T) func() {
@@ -22,6 +24,7 @@ func setupTestDB(t *testing.T) func() {
pool := testdb.Pool(t)
testdb.Migrate(t, pool)
testdb.TruncateTables(t, pool) // Clear data between tests
originalDB := db.DB
db.DB = pool
@@ -43,18 +46,56 @@ func makeRequest(handler http.HandlerFunc, method, path string, body interface{}
} else {
req = httptest.NewRequest(method, path, nil)
}
return makeRequestWithContext(handler, req)
}
// makeRequestWithContext executes request with chi routing context for path params
func makeRequestWithContext(handler http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder {
// Set up chi routing context for path params
rctx := chi.NewRouteContext()
if id, paramName := extractIDFromPath(req.URL.Path); id != "" {
rctx.URLParams.Add(paramName, id)
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// extractIDFromPath extracts the ID from URL paths
func extractIDFromPath(path string) (string, string) {
patterns := []struct {
prefix string
paramName string
}{
{"/api/services/eligible-for/", "user_id"},
}
for _, p := range patterns {
if idx := findLastSegment(path, p.prefix); idx >= 0 {
return path[idx:], p.paramName
}
}
return "", ""
}
func findLastSegment(path, prefix string) int {
for i := len(path); i >= len(prefix); i-- {
if i > 0 && path[i-len(prefix):i] == prefix {
return i
}
}
return -1
}
func TestServices_ListAll(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
_, err := db.DB.Exec(context.Background(), `
INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required)
VALUES
VALUES
('Manicure', 'Basic manicure', 25.00, 30, true, 0, 0),
('Pedicure', 'Basic pedicure', 30.00, 45, true, 0, 0),
('Inactive Service', 'Should not appear', 50.00, 60, false, 0, 0)
@@ -98,7 +139,7 @@ func TestServices_EligibleForUser_AgeFilter(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
dob := "2005-01-01"
dob := "2006-01-01" // Age 20 in Feb 2026
userID, err := createUserWithDOB(dob)
if err != nil {
t.Fatalf("failed to create user: %v", err)
@@ -106,7 +147,7 @@ func TestServices_EligibleForUser_AgeFilter(t *testing.T) {
_, err = db.DB.Exec(context.Background(), `
INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required)
VALUES
VALUES
('Under 18 Service', 'For minors', 20.00, 30, true, 0, 16),
('Adult Only Service', 'For adults only', 50.00, 60, true, 0, 21),
('No Age Restriction', 'Everyone welcome', 30.00, 45, true, 0, 0)
@@ -117,8 +158,7 @@ func TestServices_EligibleForUser_AgeFilter(t *testing.T) {
handler := http.HandlerFunc(ServicesEligibleForUserHandler)
req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
w := makeRequestWithContext(handler, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
@@ -160,7 +200,7 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) {
_, err = db.DB.Exec(context.Background(), `
INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required)
VALUES
VALUES
('Regular Service', 'No patch test needed', 30.00, 30, true, 0, 0),
('Patch Test Required', 'Requires patch test', 75.00, 60, true, 48, 0)
`)
@@ -184,8 +224,7 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) {
handler := http.HandlerFunc(ServicesEligibleForUserHandler)
req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
w := makeRequestWithContext(handler, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
@@ -231,8 +270,8 @@ func TestContact_ReturnsInfo(t *testing.T) {
defer cleanup()
_, err := db.DB.Exec(context.Background(), `
INSERT INTO users (n_first_name, n_last_name, email, phone, password_hash, account_role, account_type)
VALUES ('John', 'Smith', 'john@test.com', '07700900001', 'hash', 'admin', 'email')
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('John', 'Smith', 'john@test.com', '+447700000001', '1990-01-01', 'hash', 'admin', 'email')
`)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
@@ -268,9 +307,9 @@ func createUserWithDOB(dob string) (string, error) {
ctx := context.Background()
var userID string
err := db.DB.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, password_hash, account_role, account_type, date_of_birth)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, "Test", "User", "testuser@test.com", "hash", "verified_email", "email", dob).Scan(&userID)
`, "Test", "User", "testuser@test.com", "+44770000001", dob, "hash", "verified_email", "email").Scan(&userID)
return userID, err
}