diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 75f3a37..d09bf5e 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -3,10 +3,10 @@ package bookings import ( "crussell/db" "crussell/handlers/notifications" + "crussell/handlers/scheduling" "crussell/internal/dav" "crussell/internal/validators" "crussell/mw" - "crussell/handlers/scheduling" "database/sql" "encoding/json" "errors" @@ -138,8 +138,8 @@ type ServiceOverride struct { // DeleteBookingRequest represents the request payload for deleting a booking with payment type DeleteBookingRequest struct { - Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` - ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time + Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` + ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time } // AdminUserSummary represents a small user summary for admin views @@ -1230,10 +1230,13 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { defer tx.Rollback(r.Context()) // Delete any existing reservation for this user (max 1 per user) + // Also matches anon reservations by start_time for users who register mid-flow _, _ = tx.Exec(r.Context(), ` DELETE FROM time_blockers - WHERE created_by = $1 AND description LIKE 'RESERVATION:%' - `, userID) + WHERE description LIKE 'RESERVATION:%' + AND (created_by = $1 + OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2)) + `, userID, req.StartTime) // Insert booking with snapshotted deposit_required var booking Booking @@ -2231,17 +2234,15 @@ END:VEVENT END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status) } - - // OverlappingBooking represents a booking that overlaps with another type OverlappingBooking struct { - ID string `json:"id"` - StartTime time.Time `json:"start_time"` - Duration int `json:"duration_minutes"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - User *UserSummary `json:"user,omitempty"` - Services []string `json:"services,omitempty"` + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + Duration int `json:"duration_minutes"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + User *UserSummary `json:"user,omitempty"` + Services []string `json:"services,omitempty"` } // OverlappingBookingsResponse is the response for the overlapping bookings endpoint @@ -2353,4 +2354,4 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to encode overlapping bookings response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } -} \ No newline at end of file +} diff --git a/backend/handlers/bookings/reserve_test.go b/backend/handlers/bookings/reserve_test.go new file mode 100644 index 0000000..0395079 --- /dev/null +++ b/backend/handlers/bookings/reserve_test.go @@ -0,0 +1,379 @@ +//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") + } +} diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 687d188..7454fa5 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -19,20 +19,20 @@ import ( // --- Types --- type TimeBlocker struct { - ID string `json:"id"` - StartTime time.Time `json:"start_time"` - DurationMinutes int `json:"duration_minutes"` - Description string `json:"description,omitempty"` - CronExpression *string `json:"cron_expression,omitempty"` - CreatedAt time.Time `json:"created_at"` - CreatedBy *string `json:"created_by,omitempty"` + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + DurationMinutes int `json:"duration_minutes"` + Description string `json:"description,omitempty"` + CronExpression *string `json:"cron_expression,omitempty"` + CreatedAt time.Time `json:"created_at"` + CreatedBy *string `json:"created_by,omitempty"` } type CreateTimeBlockerRequest struct { - StartTime time.Time `json:"start_time"` - DurationMinutes int `json:"duration_minutes"` - Description string `json:"description,omitempty"` - CronExpression *string `json:"cron_expression,omitempty"` + StartTime time.Time `json:"start_time"` + DurationMinutes int `json:"duration_minutes"` + Description string `json:"description,omitempty"` + CronExpression *string `json:"cron_expression,omitempty"` } // --- List Time Blockers --- @@ -185,7 +185,6 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } - // --- Helper: Get Time Blockers in Range --- // --- Helper: Get Time Blockers in Range --- @@ -334,14 +333,17 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time) return false, "", nil } -// CleanupOldReservations deletes reservations (time_blockers with RESERVATION: description prefix) -// that are older than 1 hour. +// CleanupOldReservations deletes expired reservations: +// - Logged-in (RESERVATION:user): older than 1 hour +// - Anonymous (RESERVATION:anon): older than 10 minutes func CleanupOldReservations(ctx context.Context) error { oneHourAgo := time.Now().Add(-1 * time.Hour) + tenMinutesAgo := time.Now().Add(-10 * time.Minute) + _, err := db.DB.Exec(ctx, ` DELETE FROM time_blockers - WHERE description LIKE 'RESERVATION:%' - AND created_at < $1 - `, oneHourAgo) + WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1) + OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2) + `, oneHourAgo, tenMinutesAgo) return err } diff --git a/backend/main.go b/backend/main.go index bf9173c..166d855 100644 --- a/backend/main.go +++ b/backend/main.go @@ -137,6 +137,13 @@ func main() { }) }) + // Public booking endpoints (optional auth for slot reservation) + r.Route("/bookings", func(r chi.Router) { + r.Use(mw.RateLimit(30, time.Minute)) + r.Use(mw.OptionalAuth) + r.Post("/reserve", bookings.ReserveSlotHandler) + }) + // Authenticated users r.Group(func(r chi.Router) { r.Use(mw.RequireAuth)