refactor(bookings): migrate remaining handlers and tests to clock.Now()

Replace time.Now() with clock.Now() in bookings handlers and all test files. Includes deposit, discount, dedup, overlap, and edit request test updates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:32 +01:00
co-authored by Sisyphus
parent 0ea1bb64b4
commit 40bbd9ba49
10 changed files with 2057 additions and 494 deletions
+579 -5
View File
@@ -6,11 +6,13 @@ package bookings
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
@@ -26,7 +28,7 @@ import (
// 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()
now := clock.Now().UTC()
daysAhead := int(weekday) - int(now.Weekday())
if daysAhead <= 0 {
daysAhead += 7
@@ -836,7 +838,7 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) {
dur := durationMinutes(t, ctx, tx, serviceID)
// Use <48h from now so RequestEditHandler does NOT auto-approve
nearTime := time.Now().Add(40 * time.Hour)
nearTime := clock.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:
@@ -1008,13 +1010,15 @@ func TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails(t *testing.T) {
baseTime := weekdayTime(time.Wednesday, 10)
// Compute the Monday of the week containing baseTime
weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1)
// 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(baseTime.Weekday())
dbWeekday := int(londonBase.Weekday())
if dbWeekday == 0 {
dbWeekday = 6
} else {
@@ -1262,3 +1266,573 @@ func TestAdminCreateBooking_OverlapWithCancelled_Allowed(t *testing.T) {
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)
}
}
// 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 <48h from now so RequestEdit does NOT auto-approve
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), 10, 0, 0, 0, nearTime.Location())
if nearTime.Weekday() == time.Sunday {
nearTime = nearTime.AddDate(0, 0, 2)
} else if nearTime.Weekday() == time.Monday {
nearTime = nearTime.AddDate(0, 0, 1)
}
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)
}
}