From e23cc3cc6f7ac4493ce965eaf2e731ac484cb7e1 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 21 Jun 2026 21:01:17 +0100 Subject: [PATCH] feat(bookings): add comprehensive overlap detection tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers admin create, user edit, auto-approve, service duration extension, reserve, admin reserve, admin reschedule, confirm, and admin approve edit request — all with overlap edge cases. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/overlap_test.go | 941 ++++++++++++++++++++++ 1 file changed, 941 insertions(+) create mode 100644 backend/handlers/bookings/overlap_test.go diff --git a/backend/handlers/bookings/overlap_test.go b/backend/handlers/bookings/overlap_test.go new file mode 100644 index 0000000..c1122f0 --- /dev/null +++ b/backend/handlers/bookings/overlap_test.go @@ -0,0 +1,941 @@ +//go:build test && dev +// +build test,dev + +package bookings + +import ( + "context" + "net/http" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" +) + +// ============================================================================ +// 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 := time.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 := time.Now().Add(40 * time.Hour) + nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location()) + switch nearTime.Weekday() { + case time.Sunday: + nearTime = nearTime.AddDate(0, 0, 2) + case time.Monday: + nearTime = nearTime.AddDate(0, 0, 1) + } + + 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()) + } +} + +// ============================================================================ +// 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()) + } +}