//go:build test && dev package bookings import ( "bytes" "context" "crypto/sha256" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // ============================================================================ // Helpers // ============================================================================ // weekdayTime returns a far-future time on the given weekday at hour:00 UTC. // Ensures at least 2 weeks out so all time-window checks (deposits, advance) // pass without interference. func weekdayTime(weekday time.Weekday, hour int) time.Time { now := clock.Now().UTC() daysAhead := int(weekday) - int(now.Weekday()) if daysAhead <= 0 { daysAhead += 7 } // Push far enough forward that advance-window / deposit checks don't interfere daysAhead += 21 result := now.AddDate(0, 0, daysAhead) return time.Date(result.Year(), result.Month(), result.Day(), hour, 0, 0, 0, time.UTC) } // durationMinutes returns the duration_minutes for a given service ID from the tx. func durationMinutes(t *testing.T, ctx context.Context, tx db.Querier, serviceID string) int { t.Helper() var d int if err := tx.QueryRow(ctx, "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&d); err != nil { t.Fatalf("failed to get service duration: %v", err) } return d } // ============================================================================ // AdminCreateBookingForUserHandler — overlap detection // ============================================================================ // TestAdminCreateBooking_OverlapWithConfirmed verifies that creating a booking // at a time that overlaps an existing confirmed booking returns 409. func TestAdminCreateBooking_OverlapWithConfirmed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Create an existing confirmed booking existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID) if err != nil { t.Fatalf("failed to confirm existing booking: %v", err) } // Try to create a new booking overlapping the existing one (starts 15 min // after the existing booking starts but before it ends). overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute) body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: overlapTime, ServiceIDs: []string{serviceID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String()) } // Verify the existing booking was NOT evicted (no pending_release eviction // since the existing booking is confirmed, not pending_release). var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingID).Scan(&status) if err != nil { t.Fatalf("failed to query existing booking: %v", err) } if status != "confirmed" { t.Errorf("expected existing booking to remain 'confirmed', got %q", status) } } // TestAdminCreateBooking_AdjacentBooking_NoOverlap verifies that creating a // booking at the exact end of an existing booking (adjacent, no gap) is allowed. func TestAdminCreateBooking_AdjacentBooking_NoOverlap(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Create an existing confirmed booking [10:00, 10:00+dur) existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID) if err != nil { t.Fatalf("failed to confirm existing booking: %v", err) } // Create a new booking starting exactly when the existing one ends (adjacent, // no overlap). adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute) body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: adjacentTime, ServiceIDs: []string{serviceID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("expected 200/201 for adjacent (non-overlapping) booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBooking_ExactSameStartTime verifies that creating a booking // at the exact same start time as an existing booking is rejected. func TestAdminCreateBooking_ExactSameStartTime(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID) if err != nil { t.Fatalf("failed to confirm existing booking: %v", err) } // Same exact start time body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: baseTime, ServiceIDs: []string{serviceID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for same start time, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // EditBookingHandler - user-facing edit overlap detection // ============================================================================ // TestEditBooking_OverlapWithExisting verifies that editing a booking to an // overlapping time slot returns 409. func TestEditBooking_OverlapWithExisting(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Disable deposits for this user so the handler doesn't check deposit limits _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) token := jwt.GenerateUserToken(userID) // Create booking A (the one we're editing) at [10:00, 10:00+dur) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Create booking B at [12:00, 12:00+dur) — far enough away to not interfere initially farTime := baseTime.Add(2 * time.Hour) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to confirm booking B: %v", err) } // Now edit booking A to a time that overlaps booking B. // Move it to [11:30, 11:30+dur) — booking B is at [12:00, 12:00+dur), so // 11:30+dur > 12:00 means overlap. overlapTime := farTime.Add(-time.Duration(dur/2) * time.Minute) handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingA, map[string]interface{}{ "start_time": overlapTime.Format(time.RFC3339), "service_ids": []string{serviceID}, }, token, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping edit, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking A was NOT updated (still at original time) var actualStart time.Time err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingA).Scan(&actualStart) if err != nil { t.Fatalf("failed to query booking A: %v", err) } if !actualStart.Equal(baseTime) { t.Errorf("expected booking A start_time to remain %v, got %v", baseTime, actualStart) } } // TestEditBooking_NonOverlappingEdit_Succeeds verifies that editing a booking // to a non-overlapping time succeeds. func TestEditBooking_NonOverlappingEdit_Succeeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) newTime := baseTime.Add(24 * time.Hour) // Next day, far from any conflict token := jwt.GenerateUserToken(userID) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } _ = dur handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, map[string]interface{}{ "start_time": newTime.Format(time.RFC3339), "service_ids": []string{serviceID}, }, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for non-overlapping edit, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // RequestEditHandler — auto-approve path overlap detection // ============================================================================ // TestRequestEditHandler_AutoApprove_OverlapDetected verifies that the // auto-approve path rejects a request that would create an overlap. // Requests with new_start_time and >48h to the event trigger auto-approval // which runs the overlap check inline. func TestRequestEditHandler_AutoApprove_OverlapDetected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) token := jwt.GenerateUserToken(userID) // Booking A — user's existing confirmed booking at [10:00, 10:00+dur) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Booking B — at a far-away time (so auto-approve triggers when we set // new_start_time on an existing confirmed booking >48h out) bookingBTime := baseTime.Add(48 * time.Hour) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingBTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to confirm booking B: %v", err) } // Submit edit request with a new_start_time that overlaps A. // Since the edit request is >48h before the booking event, the auto-approve // path runs the overlap check and should reject with 409. overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute) handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingB+"/edit-request", map[string]interface{}{ "new_start_time": overlapTime.Format(time.RFC3339), }, token, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping auto-approve, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // UpdateBookingServicesHandler — extending duration into existing booking // ============================================================================ // TestUpdateBookingServices_ExtendOverlapsExisting verifies that extending a // booking's duration (by adding a second service) into an existing booking's // slot returns 409. func TestUpdateBookingServices_ExtendOverlapsExisting(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Booking A — starts at 10:00, duration = dur (ends at 10:00+dur) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Booking B — starts exactly when A ends (adjacent, no overlap initially) adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, adjacentTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to confirm booking B: %v", err) } // Create a second service with short duration (30 min) that, when added to // A, would make A extend INTO B's slot. shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30) if err != nil { t.Fatalf("failed to create short service: %v", err) } // Try to add the second service to booking A — this extends A's duration // to dur+30, which exceeds the gap to booking B. w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{ "service_ids": []string{serviceID, shortSvcID}, }, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for extending into existing booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestUpdateBookingServices_ExtendNoOverlap_Succeeds verifies that extending a // booking's duration without overlapping another booking succeeds. func TestUpdateBookingServices_ExtendNoOverlap_Succeeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Booking A — starts at 10:00 bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Booking B — starts 3 hours later (plenty of room) farTime := baseTime.Add(3 * time.Hour) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to confirm booking B: %v", err) } _ = dur // Create a second service with 30 min duration — A would be dur+30, // which still leaves plenty of gap before B. shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30) if err != nil { t.Fatalf("failed to create short service: %v", err) } w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{ "service_ids": []string{serviceID, shortSvcID}, }, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { t.Errorf("expected 200 for extending without overlap, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // ReserveSlotHandler — overlap detection (existing test was checking for // regression, this strengthens coverage) // ============================================================================ // TestReserveSlot_OverlapWithExistingBooking confirms that the reserve endpoint // returns 409 when the requested slot overlaps a confirmed booking. func TestReserveSlot_OverlapWithExistingBooking(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, userID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: baseTime, ServiceIDs: []string{serviceID}, }, "") if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // AdminReserveSlotHandler — overlap detection // ============================================================================ // TestAdminReserveSlot_OverlapWithExistingBooking verifies that admin reserve // returns 409 when the slot overlaps a confirmed booking. func TestAdminReserveSlot_OverlapWithExistingBooking(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') `, userID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } w := serveChiHandler(AdminReserveSlotHandler, "POST", "/", "/", AdminReserveSlotRequest{ StartTime: baseTime, ServiceIDs: []string{serviceID}, ReservationType: "callin", }, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, userID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping admin reserve, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // AdminRescheduleBookingHandler — overlap detection // ============================================================================ // TestAdminRescheduleBooking_OverlapWithConfirmed verifies that rescheduling // a booking to an overlapping time returns 409. func TestAdminRescheduleBooking_OverlapWithConfirmed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Booking A — occupies [10:00, 10:00+dur) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Booking B — later slot, will be rescheduled INTO A's slot bookingBTime := baseTime.Add(48 * time.Hour) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingBTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to confirm booking B: %v", err) } // Try to reschedule B into A's slot (overlap) overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute) body := map[string]interface{}{ "start_time": overlapTime.Format(time.RFC3339), } w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingB+"/reschedule", "/{id}/reschedule", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for reschedule overlapping booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // ConfirmBookingHandler — overlap detection // ============================================================================ // TestConfirmBooking_OverlapWithConfirmed verifies that confirming a booking // that would overlap an existing confirmed booking returns 409. func TestConfirmBooking_OverlapWithConfirmed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Booking A — confirmed, occupies [10:00, 10:00+dur) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Booking B — pending, at the same time (would overlap if confirmed) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime.Add(15*time.Minute)) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to set booking B to pending: %v", err) } _ = dur // Confirm booking B — should be rejected because it would overlap A w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+bookingB+"/confirm", "/{id}/confirm", map[string]interface{}{}, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for confirm overlapping booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestConfirmBooking_NoOverlap_Succeeds verifies that confirming a non-overlapping // pending booking succeeds. func TestConfirmBooking_NoOverlap_Succeeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Existing confirmed booking existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID) if err != nil { t.Fatalf("failed to confirm existing booking: %v", err) } // Pending booking at a non-overlapping time (adjacent, exactly when A ends) pendingStart := baseTime.Add(time.Duration(dur) * time.Minute) pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingStart) if err != nil { t.Fatalf("failed to create pending booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", pendingID) if err != nil { t.Fatalf("failed to set pending: %v", err) } w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+pendingID+"/confirm", "/{id}/confirm", map[string]interface{}{}, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { t.Errorf("expected 200 for confirming non-overlapping booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // AdminApproveEditRequestHandler — overlap at approval time // ============================================================================ // TestAdminApproveEditRequest_OverlapWithBooking is already tested in // edit_requests_test.go:1607. This test re-verifies the same scenario with // the updated handler to guard against regression. func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) // Use <48h from now so RequestEditHandler does NOT auto-approve nearTime := clock.Now().Add(40 * time.Hour) nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location()) token := jwt.GenerateUserToken(userID) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } bookingBTime := nearTime.Add(2 * time.Hour) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingBTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to confirm booking B: %v", err) } overlapTime := nearTime.Add(time.Duration(dur/2) * time.Minute) handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingB+"/edit-request", map[string]interface{}{ "new_start_time": overlapTime.Format(time.RFC3339), }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d. body: %s", w.Code, w.Body.String()) } editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingB) approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingB+"/edit-requests/"+editRequestID+"/approve", "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for approve overlapping request, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================ // AdminCreateBookingForUserHandler — out_of_hours flag tests // ============================================================================ // TestAdminCreateBooking_OutOfHours_ExceptionalClosed verifies that when // out_of_hours=true is set, the admin can create a booking during a period // that would normally be closed due to exceptional hours (holiday closure). func TestAdminCreateBooking_OutOfHours_ExceptionalClosed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) // Compute the Monday of the week containing baseTime weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1) weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC) weekStartStr := weekStart.Format("2006-01-02") // Compute weekday in DB format (0=Monday..6=Sunday) dbWeekday := int(baseTime.Weekday()) if dbWeekday == 0 { dbWeekday = 6 } else { dbWeekday -= 1 } // Create an exceptional hours group that makes this weekday CLOSED var groupID int err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test Closure', 'Exceptional closure for out-of-hours test') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional hours group: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '00:00', '23:59', false) `, groupID, dbWeekday) if err != nil { t.Fatalf("failed to seed exceptional hours: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2::date) `, groupID, weekStartStr) if err != nil { t.Fatalf("failed to seed exceptional group application: %v", err) } // Try creating a booking with out_of_hours=true during the closed exceptional period body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: baseTime, ServiceIDs: []string{serviceID}, OutOfHours: true, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("expected 200/201 for out-of-hours booking during exceptional closure, got %d. body: %s", w.Code, w.Body.String()) } // Verify the response body includes out_of_hours=true var createResp struct { Booking Booking `json:"booking"` } if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil { t.Fatalf("failed to parse create response: %v", err) } if !createResp.Booking.OutOfHours { t.Error("expected out_of_hours=true in create booking response") } } // TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails verifies that when // out_of_hours=false (default), creating a booking during a closed exceptional // hours period is rejected with a conflict error. func TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) // Compute the Monday of the week containing baseTime using London timezone // for the weekday, but UTC midnight for the DATE — matching the handler's pattern. londonBase := baseTime.In(londonLocation) weekStart := londonBase.AddDate(0, 0, -int(londonBase.Weekday())+1) weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC) weekStartStr := weekStart.Format("2006-01-02") // Compute weekday in DB format (0=Monday..6=Sunday) dbWeekday := int(londonBase.Weekday()) if dbWeekday == 0 { dbWeekday = 6 } else { dbWeekday -= 1 } // Create an exceptional hours group that makes this weekday CLOSED var groupID int err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test Closure', 'Exceptional closure for without-flag test') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional hours group: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '00:00', '23:59', false) `, groupID, dbWeekday) if err != nil { t.Fatalf("failed to seed exceptional hours: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2::date) `, groupID, weekStartStr) if err != nil { t.Fatalf("failed to seed exceptional group application: %v", err) } // Try creating a booking WITHOUT out_of_hours during the closed exceptional period body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: baseTime, ServiceIDs: []string{serviceID}, // OutOfHours intentionally false (zero value) } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for booking during exceptional closure without out_of_hours flag, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBooking_OutOfHours_OverlapStillChecked verifies that even // when out_of_hours=true, overlap detection with existing bookings still works. func TestAdminCreateBooking_OutOfHours_OverlapStillChecked(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Create an existing booking at baseTime (confirmed) existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID) if err != nil { t.Fatalf("failed to confirm existing booking: %v", err) } // Try creating an out-of-hours booking that overlaps (starts dur/2 min after) overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute) body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: overlapTime, ServiceIDs: []string{serviceID}, OutOfHours: true, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping out-of-hours booking, got %d. body: %s", w.Code, w.Body.String()) } // Verify the existing booking was NOT affected var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingID).Scan(&status) if err != nil { t.Fatalf("failed to query existing booking: %v", err) } if status != "confirmed" { t.Errorf("expected existing booking to remain 'confirmed', got %q", status) } } // TestAdminCreateBooking_OutOfHours_TimeBlockerWarning verifies that when // out_of_hours=true is set and a time blocker overlaps, the booking is still // created with a warning (rather than being rejected), matching the behavior // of the non-out-of-hours create flow. func TestAdminCreateBooking_OutOfHours_TimeBlockerWarning(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) // Create a time blocker at the same time as the booking _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Admin Blocked', NULL) `, baseTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: baseTime, ServiceIDs: []string{serviceID}, OutOfHours: true, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) // AdminCreateBookingForUserHandler issues warnings but does not reject // for time blocker overlaps — the booking is created with a warning. if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("expected 200/201 (booking created with warning), got %d. body: %s", w.Code, w.Body.String()) } // Verify the response includes the time blocker warning var createResp struct { Warnings []string `json:"warnings"` } if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(createResp.Warnings) == 0 { t.Error("expected warning about time blocker overlap in response") } foundBlockerWarning := false for _, warn := range createResp.Warnings { if strings.Contains(warn, "time blocker") { foundBlockerWarning = true break } } if !foundBlockerWarning { t.Errorf("expected warning to mention time blocker, got warnings: %v", createResp.Warnings) } } // ============================================================================ // Bookings status evolution — overlap edge cases // ============================================================================ // TestAdminCreateBooking_OverlapWithCancelled_Allowed verifies that creating a // booking overlapping a cancelled booking is allowed (cancelled bookings are // excluded from overlap checks). func TestAdminCreateBooking_OverlapWithCancelled_Allowed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) cancelledID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", cancelledID) if err != nil { t.Fatalf("failed to set client_cancelled: %v", err) } // Creating a booking overlapping a cancelled one should be OK body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: baseTime, ServiceIDs: []string{serviceID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("expected 200/201 for overlapping completed booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBooking_Weekday_BST_Boundary verifies that AdminCreateBookingForUserHandler // uses London timezone for the exceptional-hours weekday lookup (Issues 2+3 fix). // At 23:30 UTC on a Sunday in BST (= 00:30 BST Monday), the handler should look up // Monday's exceptional hours, not Sunday's. Sunday's row is deleted here, so without // the fix the lookup would fail the handler. With the fix (London time weekday=Monday), // the row exists and the handler succeeds. func TestAdminCreateBooking_Weekday_BST_Boundary(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } // Delete Sunday's working hours so a UTC-weekday lookup (Sunday, DB weekday 6) fails. _, err = tx.Exec(ctx, "DELETE FROM working_hours WHERE weekday = 6") if err != nil { t.Fatalf("failed to delete Sunday hours: %v", err) } // Book at 23:30 UTC on Sunday (= 00:30 BST Monday). Without London weekday, // DB weekday = Sunday (6, row deleted). With London weekday = Monday (0, closed via EH). sunday2330UTC := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC) // Compute weekStart the same way the handler does: from the booking time's // London weekday, find Monday's date, store as UTC midnight. bkLondon := sunday2330UTC.In(londonLocation) // 00:30 BST Monday daysToMonday := int(bkLondon.Weekday()) // Monday in Go = 1 if daysToMonday == 0 { daysToMonday = 7 } tm := bkLondon.AddDate(0, 0, -daysToMonday+1) weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC) weekStartStr := weekStart.Format("2006-01-02") var groupID int err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test', '') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create group: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, 0, '00:00', '23:59', false) `, groupID) // Monday (DB weekday 0) closed if err != nil { t.Fatalf("failed to seed exceptional hours: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2::date) `, groupID, weekStartStr) if err != nil { t.Fatalf("failed to seed application: %v", err) } body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: sunday2330UTC, ServiceIDs: []string{serviceID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) // The handler must reject the booking (BST boundary). It may use 400 or 409 // depending on whether it hits the working_hours lookup or the EH check first. // The important thing is it does NOT return 200/500. if w.Code == http.StatusOK || w.Code == http.StatusCreated { t.Errorf("expected 4xx rejection at BST boundary, got %d. body: %s", w.Code, w.Body.String()) } if w.Code == http.StatusInternalServerError { t.Errorf("unexpected 500 — likely a DB lookup failed due to wrong weekday at BST boundary") } } // ============================================================================ // pending_release eviction tests — every handler that calls // EvictPendingReleaseOverlapping must be tested for correct eviction. // ============================================================================ // TestUpdateBookingServices_ExtendEvictsPendingRelease verifies that extending // a booking's duration into a pending_release slot evicts it (→ deposit_lapsed) // rather than rejecting the extension. func TestUpdateBookingServices_ExtendEvictsPendingRelease(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Booking A — starts at 10:00, confirmed bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Booking B — starts adjacent to A, set to pending_release adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, adjacentTime) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to set booking B to pending_release: %v", err) } // Add a 30-min service to A — extends A into B's slot shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30) if err != nil { t.Fatalf("failed to create short service: %v", err) } w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{ "service_ids": []string{serviceID, shortSvcID}, }, func(baseCtx context.Context) context.Context { baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { t.Fatalf("expected 200 after evicting pending_release, got %d. body: %s", w.Code, w.Body.String()) } // Verify B was evicted to deposit_lapsed var newStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingB).Scan(&newStatus) if err != nil { t.Fatalf("failed to query booking B: %v", err) } if newStatus != "deposit_lapsed" { t.Errorf("expected booking B to be evicted to 'deposit_lapsed', got %q", newStatus) } } // TestEditBooking_EvictsPendingReleaseOnOverlap verifies that editing a booking's // start time into a pending_release slot evicts it rather than blocking the edit. func TestEditBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } _ = durationMinutes(t, ctx, tx, serviceID) baseTime := weekdayTime(time.Wednesday, 10) // Create the user's own confirmed booking bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking A: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking A: %v", err) } // Create a pending_release booking 1h later that A will be edited into pendingStart := baseTime.Add(1 * time.Hour) bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingStart) if err != nil { t.Fatalf("failed to create booking B: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingB) if err != nil { t.Fatalf("failed to set booking B to pending_release: %v", err) } // Edit booking A's time to overlap B's slot w := makeRequest(http.HandlerFunc(EditBookingHandler), "PUT", "/api/bookings/"+bookingA, map[string]interface{}{ "start_time": pendingStart.Add(-15 * time.Minute).Format(time.RFC3339), }, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200 after evicting pending_release, got %d. body: %s", w.Code, w.Body.String()) } // Verify B was evicted to deposit_lapsed var newStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingB).Scan(&newStatus) if err != nil { t.Fatalf("failed to query booking B: %v", err) } if newStatus != "deposit_lapsed" { t.Errorf("expected booking B to be evicted to 'deposit_lapsed', got %q", newStatus) } } // TestRequestEdit_AutoApprove_EvictsPendingRelease verifies that the // RequestEditHandler auto-approve path evicts overlapping pending_release // bookings when it changes the booking's start time. func TestRequestEdit_AutoApprove_EvictsPendingRelease(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } _ = durationMinutes(t, ctx, tx, serviceID) // Booking at a far-future time (>48h from now) so auto-approve triggers farTime := clock.Now().Add(120 * time.Hour).Truncate(time.Second) farTime = time.Date(farTime.Year(), farTime.Month(), farTime.Day(), 10, 0, 0, 0, farTime.Location()) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a pending_release booking at a slightly later time pendingTime := farTime.Add(1 * time.Hour) pendingBooking, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingTime) if err != nil { t.Fatalf("failed to create pending_release booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingBooking) if err != nil { t.Fatalf("failed to set pending_release: %v", err) } // Request edit to move A into B's slot — auto-approve should evict B handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingA+"/edit-request", map[string]interface{}{ "new_start_time": pendingTime.Format(time.RFC3339), }, token, ctx) if w.Code != http.StatusOK && w.Code != http.StatusCreated { t.Fatalf("expected 200/201 for auto-approved edit (pending_release evicted), got %d. body: %s", w.Code, w.Body.String()) } // Verify pending_release was evicted to deposit_lapsed var newStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingBooking).Scan(&newStatus) if err != nil { t.Fatalf("failed to query pending booking: %v", err) } if newStatus != "deposit_lapsed" { t.Errorf("expected pending_release booking to be evicted to 'deposit_lapsed', got %q", newStatus) } } // TestCreateBooking_ReservationDoesNotSelfBlock verifies that the reservation // time_blocker created by the reserve step does NOT block CreateBookingHandler. // The reservation cleanup must happen BEFORE CheckTimeBlockerOverlap. func TestCreateBooking_ReservationDoesNotSelfBlock(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } // Use a far-future weekday so closing-time and deposit checks pass future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Simulate the reserve step: create a RESERVATION time_blocker at this slot // using the same description format as ReserveSlotHandler for logged-in users desc := fmt.Sprintf("RESERVATION:user:%s:%d", userID, clock.Now().UnixNano()) var blockerID string err = tx.QueryRow(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, $3, $4) RETURNING id `, future, 60, desc, userID).Scan(&blockerID) if err != nil { t.Fatalf("failed to create reservation time_blocker: %v", err) } // Now call CreateBookingHandler — must succeed despite the reservation w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings", &CreateBookingRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token, ctx) if w.Code == http.StatusConflict { t.Fatalf("reservation time_blocker should NOT self-block CreateBookingHandler: got 409. body: %s", w.Body.String()) } if w.Code != http.StatusCreated { t.Fatalf("expected 201 after reservation cleanup, got %d. body: %s", w.Code, w.Body.String()) } // Verify the reservation was also cleaned up inside the transaction var remaining int tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", future).Scan(&remaining) if remaining != 0 { t.Errorf("expected reservation to be cleaned up, got %d remaining", remaining) } } // TestCreateBooking_ReservationDoesNotSelfBlock_Anonymous verifies that an // anonymous RESERVATION:anon: time_blocker (created_by = NULL) does NOT block // CreateBookingHandler. This simulates an anonymous user who reserves a slot, // then logs in and creates the booking. func TestCreateBooking_ReservationDoesNotSelfBlock_Anonymous(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Create an ANONYMOUS reservation (created_by = NULL) at this slot. // This is what ReserveSlotHandler creates for anonymous users. var blockerID string err = tx.QueryRow(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, $3, NULL) RETURNING id `, future, 60, fmt.Sprintf("RESERVATION:anon:testhash:%d", clock.Now().UnixNano())).Scan(&blockerID) if err != nil { t.Fatalf("failed to create anon reservation: %v", err) } // User is now logged in — CreateBookingHandler must clear the anon // reservation via the start_time match. w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings", &CreateBookingRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token, ctx) if w.Code == http.StatusConflict { t.Fatalf("anonymous reservation should NOT self-block after login: got 409. body: %s", w.Body.String()) } if w.Code != http.StatusCreated { t.Fatalf("expected 201 for anon reservation cleanup, got %d. body: %s", w.Code, w.Body.String()) } // Verify the anon reservation was cleaned up var remaining int tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining) if remaining != 0 { t.Errorf("expected anonymous reservation to be cleaned up, got %d remaining", remaining) } } // TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch // verifies that an anonymous RESERVATION for a DIFFERENT time slot is NOT // deleted — only reservations at the exact start_time being booked. func TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Create an anonymous reservation at a DIFFERENT time differentTime := future.Add(2 * time.Hour) var blockerID string err = tx.QueryRow(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, $3, NULL) RETURNING id `, differentTime, 60, fmt.Sprintf("RESERVATION:anon:testhash:%d", clock.Now().UnixNano())).Scan(&blockerID) if err != nil { t.Fatalf("failed to create anon reservation: %v", err) } // Book a DIFFERENT slot — the anon reservation should remain untouched w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings", &CreateBookingRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201 for non-conflicting slot, got %d. body: %s", w.Code, w.Body.String()) } // Verify the anon reservation at the other time was NOT deleted var remaining int tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining) if remaining != 1 { t.Errorf("expected anon reservation at different time to remain, got %d", remaining) } } // TestReserveSlot_DoesNotSelfBlock verifies that calling ReserveSlotHandler // for the same slot twice (logged-in) does not fail. func TestReserveSlot_DoesNotSelfBlock(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } future := weekdayTime(time.Monday, 10) w := makeRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve", &ReserveSlotRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("first reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) } w = makeRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve", &ReserveSlotRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token, ctx) if w.Code == http.StatusConflict { t.Fatalf("second reserve: self-blocked (got 409) — ReserveSlotHandler should not self-block. body: %s", w.Body.String()) } if w.Code != http.StatusCreated { t.Fatalf("second reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminReserveSlot_DoesNotSelfBlock verifies that calling AdminReserveSlotHandler // for the same slot twice does not fail. func TestAdminReserveSlot_DoesNotSelfBlock(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Create an admin user in the DB so the foreign key constraint is satisfied adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) if err != nil { t.Fatalf("failed to set admin role: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") future := weekdayTime(time.Monday, 11) reqBody := AdminReserveSlotRequest{ StartTime: future, DurationMinutes: 60, ReservationType: "walkin", TTLMinutes: 15, } w := makeRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve", reqBody, adminToken, ctx) if w.Code != http.StatusCreated { t.Fatalf("first admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) } w = makeRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve", reqBody, adminToken, ctx) if w.Code == http.StatusConflict { t.Fatalf("second admin reserve: self-blocked (got 409) — AdminReserveSlotHandler should not self-block. body: %s", w.Body.String()) } if w.Code != http.StatusCreated { t.Fatalf("second admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminApproveEditRequest_EvictsPendingRelease verifies that approving an // edit request evicts overlapping pending_release bookings at the new time slot. func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) // Use a booking 24-48h from now at a fixed working-hour slot so RequestEdit // neither 403s (<24h away = too close) nor auto-approves (>48h away would // consume the edit request before the admin can approve it). A raw // clock.Now().Add(Kh) can land after 19:00 London and fail closing hours. nearTime := fixtures.NextWorkingDayAt(1, 10) if nearTime.Sub(clock.Now()) < 24*time.Hour { nearTime = fixtures.NextWorkingDayAt(2, 10) } bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create an edit request to move A to a new time newTime := nearTime.Add(2 * time.Hour) reqHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(reqHandler, "POST", "/api/bookings/"+bookingA+"/edit-request", map[string]interface{}{ "new_start_time": newTime.Format(time.RFC3339), }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d. body: %s", w.Code, w.Body.String()) } // Get the edit request ID var editRequestID string err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingA).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } // Create a pending_release booking at the NEW target time (overlapping the edit request) pendingBooking, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, newTime) if err != nil { t.Fatalf("failed to create pending_release booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingBooking) if err != nil { t.Fatalf("failed to set pending_release: %v", err) } _ = dur // Approve the edit request as admin — should evict the pending_release approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingA+"/edit-requests/"+editRequestID+"/approve", "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Fatalf("expected 200/204 for approve after evicting pending_release, got %d. body: %s", w.Code, w.Body.String()) } // Verify pending_release was evicted to deposit_lapsed var newStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingBooking).Scan(&newStatus) if err != nil { t.Fatalf("failed to query pending booking: %v", err) } if newStatus != "deposit_lapsed" { t.Errorf("expected pending_release to be evicted to 'deposit_lapsed', got %q", newStatus) } } // TestReserveSlot_CleansUpAnonReservation verifies that ReserveSlotHandler // cleans up anonymous RESERVATION:anon entries matching the user's IP // when the user is authenticated (IP hash matching in pre-overlap DELETE). func TestReserveSlot_CleansUpAnonReservation(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } future := weekdayTime(time.Monday, 10) // Set a known IP that the test request will use testIP := "192.0.2.1" ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(testIP)))[:8] // Create an anonymous reservation (created_by = NULL) matching this IP hash var blockerID string err = tx.QueryRow(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, $3, NULL) RETURNING id `, future, 60, fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())).Scan(&blockerID) if err != nil { t.Fatalf("failed to create anon reservation: %v", err) } // Call ReserveSlotHandler with CF-Connecting-IP header set to match the anon reservation makeIPRequest := func(handler http.Handler, method, path string, body interface{}, token string, ip string, requestCtx ...context.Context) *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) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("CF-Connecting-IP", ip) baseCtx := req.Context() if len(requestCtx) > 0 { baseCtx = requestCtx[0] } rctx := chi.NewRouteContext() ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // First call should succeed — creates RESERVATION:user entry w := makeIPRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve", &ReserveSlotRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token, testIP, ctx) if w.Code != http.StatusCreated { t.Fatalf("first reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify the anon reservation was cleaned up by the pre-overlap DELETE var remaining int tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining) if remaining != 0 { t.Errorf("expected anonymous reservation to be cleaned up by pre-overlap DELETE, got %d remaining", remaining) } } // TestEditBooking_DoesNotSelfBlock_OwnReservation verifies that a user's own // RESERVATION does not block EditBookingHandler — the excludeUserID parameter // prevents the reservation from appearing as a time blocker conflict. func TestEditBooking_DoesNotSelfBlock_OwnReservation(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } dur := durationMinutes(t, ctx, tx, serviceID) token := jwt.GenerateUserToken(userID) baseTime := weekdayTime(time.Wednesday, 10) // Create a confirmed booking bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a RESERVATION for this user at a time that overlaps with the edit target reservationTime := baseTime.Add(time.Duration(dur) * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':' || EXTRACT(epoch FROM NOW())::bigint::text, $2) `, reservationTime, userID) if err != nil { t.Fatalf("failed to create reservation: %v", err) } // Edit the booking to a time that overlaps the reservation. // Without excludeUserID, this would return 409. overlapTime := reservationTime.Add(-time.Duration(dur/2) * time.Minute) handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, map[string]interface{}{ "start_time": overlapTime.Format(time.RFC3339), "service_ids": []string{serviceID}, }, token, ctx) if w.Code == http.StatusConflict { t.Fatalf("own reservation should NOT self-block EditBookingHandler: got 409. body: %s", w.Body.String()) } if w.Code != http.StatusOK { t.Fatalf("expected 200 for edit (own reservation excluded), got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminRescheduleBooking_DoesNotSelfBlock verifies that an admin's own // RESERVATION does not block AdminRescheduleBookingHandler. func TestAdminRescheduleBooking_DoesNotSelfBlock(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } token := jwt.GenerateTestToken(adminID, "admin") serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) // Create a booking belonging to a regular user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create an admin RESERVATION at a time that would overlap the reschedule target reservationTime := baseTime.Add(2 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:' || EXTRACT(epoch FROM NOW())::bigint::text, $2) `, reservationTime, adminID) if err != nil { t.Fatalf("failed to create admin reservation: %v", err) } // Reschedule to a time overlapping the admin's own reservation overlapTime := reservationTime.Add(-30 * time.Minute) handler := http.HandlerFunc(AdminRescheduleBookingHandler) w := makeAuthRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{ "start_time": overlapTime.Format(time.RFC3339), }, token, "", ctx) if w.Code == http.StatusConflict { t.Fatalf("admin's own reservation should NOT self-block AdminRescheduleBookingHandler: got 409. body: %s", w.Body.String()) } if w.Code != http.StatusOK { t.Fatalf("expected 200 for reschedule (own reservation excluded), got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminReserveSlot_CleansUpAnonReservation verifies that AdminReserveSlotHandler // cleans up anonymous RESERVATION:anon entries matching the admin's IP // when the admin is authenticated. func TestAdminReserveSlot_CleansUpAnonReservation(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } token := jwt.GenerateTestToken(adminID, "admin") serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } future := weekdayTime(time.Monday, 10) testIP := "192.0.2.2" ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(testIP)))[:8] // Create an anonymous reservation matching this IP var blockerID string err = tx.QueryRow(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, NULL) RETURNING id `, future, fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())).Scan(&blockerID) if err != nil { t.Fatalf("failed to create anon reservation: %v", err) } makeIPRequest := func(handler http.Handler, method, path string, body interface{}, token, ip, userID string, requestCtx ...context.Context) *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) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("CF-Connecting-IP", ip) baseCtx := req.Context() if len(requestCtx) > 0 { baseCtx = requestCtx[0] } baseCtx = context.WithValue(baseCtx, mw.UserIDKey, userID) baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } w := makeIPRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve", &AdminReserveSlotRequest{ StartTime: future, ServiceIDs: []string{serviceID}, DurationMinutes: 60, ReservationType: "walkin", }, token, testIP, adminID, ctx) if w.Code != http.StatusCreated { t.Fatalf("admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) } var remaining int tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining) if remaining != 0 { t.Errorf("expected anonymous reservation to be cleaned up by admin pre-overlap DELETE, got %d remaining", remaining) } }