Files
Crussell/backend/handlers/bookings/reserve_test.go
T
popertots e73c96b653 refactor: optimize test DB setup — TestMain per package, truncate-only between tests
- Add TestMain to all 10 test packages (schema DROP+CREATE runs once per package)
- Convert per-test setupTestDB to resetTestData (TRUNCATE only, ~60% faster)
- Add 3 missing tables to TruncateTables (booking_edit_requests, exceptional_group_applications, business_settings)
- Remove dead truncateDiscountTables helper
- Consolidate discount_test.go into package bookings (was external test package)
- Update testutils.SetupTestDB to truncate-only
- Fix unused imports across user, bookings, and handlers packages
- Verify: 286 passing, 2 skipped, 0 failures with -count=2 (no state leakage)
2026-05-10 17:27:51 +01:00

368 lines
12 KiB
Go

//go:build test
// +build test
package bookings
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/handlers/scheduling"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func resetReserveTestData(t *testing.T) {
t.Helper()
testdb.TruncateTables(t, db.DB)
seedDefaultWorkingHours(t)
}
func makeReserveRequest(method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
// Add chi middleware stack for proper routing context
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
// Set user context from token if present
if token != "" {
if info := extractUserFromTestJWT(token); info != nil {
rctx := chi.NewRouteContext()
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
req = req.WithContext(ctx)
}
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(ReserveSlotHandler)
r.Post("/api/bookings/reserve", handler)
r.ServeHTTP(w, req)
return w
}
func reserveTestToken(t *testing.T, userID, role string) string {
t.Helper()
return jwt.GenerateTestToken(userID, role)
}
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
func TestReserveSlot_LoggedIn(t *testing.T) {
resetReserveTestData(t)
userID, _ := fixtures.CreateTestUser(db.DB)
token := reserveTestToken(t, userID, "verified_email")
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
reqBody := ReserveSlotRequest{
StartTime: startTime,
ServiceIDs: []string{serviceID},
}
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, token)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response ReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.IsAnonymous {
t.Error("expected is_anonymous=false for logged-in user")
}
if response.DurationMinutes == 0 {
t.Error("expected non-zero duration")
}
}
// TestReserveSlot_LoggedIn_ReplacesExisting verifies creating a second reservation
// for the same user deletes the first one (max 1 per user).
func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
resetReserveTestData(t)
userID, _ := fixtures.CreateTestUser(db.DB)
token := reserveTestToken(t, userID, "verified_email")
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
serviceIDs := []string{serviceID}
// First reservation
startTime1 := time.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
w1 := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: startTime1,
ServiceIDs: serviceIDs,
}, token)
if w1.Code != http.StatusCreated {
t.Fatalf("first reservation failed: %d", w1.Code)
}
// Second reservation (should replace first)
startTime2 := time.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
w2 := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: startTime2,
ServiceIDs: serviceIDs,
}, token)
if w2.Code != http.StatusCreated {
t.Fatalf("second reservation failed: %d", w2.Code)
}
// Verify only one user reservation exists
var count int
err = db.DB.QueryRow(context.Background(), `
SELECT COUNT(*) FROM time_blockers
WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1
`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to count reservations: %v", err)
}
if count != 1 {
t.Errorf("expected 1 reservation for user, got %d", count)
}
}
// TestReserveSlot_Anonymous verifies anonymous users can reserve a slot.
func TestReserveSlot_Anonymous(t *testing.T) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
serviceIDs := []string{serviceID}
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
reqBody := ReserveSlotRequest{
StartTime: startTime,
ServiceIDs: serviceIDs,
}
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, "")
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response ReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !response.IsAnonymous {
t.Error("expected is_anonymous=true for anonymous user")
}
}
// TestReserveSlot_ValidationErrors verifies that missing or invalid
// fields result in 400 Bad Request.
func TestReserveSlot_ValidationErrors(t *testing.T) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
serviceIDs := []string{serviceID}
// Missing start_time
w := makeReserveRequest("POST", "/api/bookings/reserve", map[string]interface{}{
"service_ids": serviceIDs,
}, "")
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String())
}
// Missing service_ids
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
w = makeReserveRequest("POST", "/api/bookings/reserve", map[string]interface{}{
"start_time": startTime,
}, "")
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing service_ids, got %d. body: %s", w.Code, w.Body.String())
}
// Past start_time
w = makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: time.Now().Add(-1 * time.Hour),
ServiceIDs: serviceIDs,
}, "")
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for past start_time, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestReserveSlot_BlockedByExistingBooking verifies that reserving a slot
// that overlaps an existing booking returns 409 Conflict.
func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create a fixture user and booking at the same time
userID, _ := fixtures.CreateTestUser(db.DB)
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', false)
`, userID, bookingStart)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: bookingStart,
ServiceIDs: []string{serviceID},
}, "")
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestReserveSlot_BlockedByTimeBlocker verifies that reserving a blocked
// time slot returns 409 Conflict.
func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Admin Blocked', NULL)
`, blockerStart)
if err != nil {
t.Fatalf("failed to create blocker: %v", err)
}
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: blockerStart,
ServiceIDs: []string{serviceID},
}, "")
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestReserveSlot_DualCleanup verifies that CleanupOldReservations deletes
// anon reservations after 10 minutes and user reservations after 1 hour.
func TestReserveSlot_DualCleanup(t *testing.T) {
resetReserveTestData(t)
ctx := context.Background()
// Create fixture users for user reservations
user1ID, _ := fixtures.CreateTestUser(db.DB)
user2ID, _ := fixtures.CreateTestUser(db.DB)
// Create old anon reservation (15 min ago)
_, err := db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL)
`, time.Now().Add(24*time.Hour), time.Now().Add(-15*time.Minute))
if err != nil {
t.Fatalf("failed to create old anon reservation: %v", err)
}
// Create recent anon reservation (5 min ago) - should survive
_, err = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL)
`, time.Now().Add(48*time.Hour), time.Now().Add(-5*time.Minute))
if err != nil {
t.Fatalf("failed to create recent anon reservation: %v", err)
}
// Create old user reservation (45 min ago) - should survive (> 10min, < 1hr)
_, err = db.DB.Exec(ctx, fmt.Sprintf(`
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
`, user1ID), time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute), user1ID)
if err != nil {
t.Fatalf("failed to create old user reservation: %v", err)
}
// Create very old user reservation (2 hours ago) - should be deleted
_, err = db.DB.Exec(ctx, fmt.Sprintf(`
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
`, user2ID), time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour), user2ID)
if err != nil {
t.Fatalf("failed to create very old user reservation: %v", err)
}
// Run cleanup
err = scheduling.CleanupOldReservations(ctx)
if err != nil {
t.Fatalf("CleanupOldReservations failed: %v", err)
}
// Verify old anon was deleted
var anonOldCount int
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount)
if anonOldCount > 0 {
t.Error("expected old anon reservation to be deleted")
}
// Verify recent anon survived
var anonRecentCount int
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount)
if anonRecentCount != 1 {
t.Error("expected recent anon reservation to survive")
}
// Verify 45-min user reservation survived
var user45Count int
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count)
if user45Count != 1 {
t.Error("expected 45-min user reservation to survive")
}
// Verify 2hr user reservation was deleted
var user2hrCount int
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount)
if user2hrCount > 0 {
t.Error("expected 2-hour user reservation to be deleted")
}
}