//go:build test // +build test package bookings import ( "bytes" "context" "encoding/json" "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 setupReserveTestDB(t *testing.T) func() { t.Helper() pool := testdb.Pool(t) testdb.Migrate(t, pool) testdb.TruncateTables(t, pool) originalDB := db.DB db.DB = pool jwt.Init() seedDefaultWorkingHours(t) return func() { db.DB = originalDB pool.Close() } } 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() token, err := jwt.GenerateTestJWT(userID, role, time.Hour) if err != nil { t.Fatalf("failed to generate test JWT: %v", err) } return token } // TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot. func TestReserveSlot_LoggedIn(t *testing.T) { cleanup := setupReserveTestDB(t) defer cleanup() token := reserveTestToken(t, "user-001", "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: serviceIDs, } 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) { cleanup := setupReserveTestDB(t) defer cleanup() token := reserveTestToken(t, "user-002", "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 = 'user-002' `).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) { cleanup := setupReserveTestDB(t) defer cleanup() 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) { cleanup := setupReserveTestDB(t) defer cleanup() 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) { cleanup := setupReserveTestDB(t) defer cleanup() // Create a booking at the same time 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) `, "fixture-user", bookingStart) if err != nil { t.Fatalf("failed to create booking: %v", err) } w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: bookingStart, ServiceIDs: serviceIDs, }, "") 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) { cleanup := setupReserveTestDB(t) defer cleanup() 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: serviceIDs, }, "") 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) { cleanup := setupReserveTestDB(t) defer cleanup() ctx := context.Background() // 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, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:user:user-001:1234', $2, 'user-001') `, time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute)) 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, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:user:user-002:1234', $2, 'user-002') `, time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour)) 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 'RESERVATION:user:user-001:%'`).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 'RESERVATION:user:user-002:%'`).Scan(&user2hrCount) if user2hrCount > 0 { t.Error("expected 2-hour user reservation to be deleted") } }