feat(bookings): add comprehensive overlap detection tests
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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user