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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user