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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user