//go:build test && dev // +build test,dev package bookings import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/handlers/scheduling" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) func makeReserveRequest(ctx context.Context, method, path string, body interface{}, token string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } // Add chi middleware stack for proper routing context r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.ClientIPFromRemoteAddr) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } // Set up chi routing context and user context from token if present if token != "" { if info := extractUserFromTestJWT(token); info != nil { rctx := chi.NewRouteContext() ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } } req = req.WithContext(ctx) w := httptest.NewRecorder() handler := http.HandlerFunc(ReserveSlotHandler) r.Post("/api/bookings/reserve", handler) r.ServeHTTP(w, req) return w } func reserveTestToken(t *testing.T, userID, role string) string { t.Helper() return jwt.GenerateTestToken(userID, role) } // TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot. func TestReserveSlot_LoggedIn(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, _ := fixtures.CreateTestUser(tx) token := reserveTestToken(t, userID, "verified_email") serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) reqBody := ReserveSlotRequest{ StartTime: startTime, ServiceIDs: []string{serviceID}, } w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var response ReserveSlotResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if response.IsAnonymous { t.Error("expected is_anonymous=false for logged-in user") } if response.DurationMinutes == 0 { t.Error("expected non-zero duration") } } // TestReserveSlot_LoggedIn_ReplacesExisting verifies creating a second reservation // for the same user deletes the first one (max 1 per user). func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, _ := fixtures.CreateTestUser(tx) token := reserveTestToken(t, userID, "verified_email") serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } serviceIDs := []string{serviceID} // First reservation startTime1 := clock.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) w1 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: startTime1, ServiceIDs: serviceIDs, }, token) if w1.Code != http.StatusCreated { t.Fatalf("first reservation failed: %d", w1.Code) } // Second reservation (should replace first) startTime2 := clock.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: startTime2, ServiceIDs: serviceIDs, }, token) if w2.Code != http.StatusCreated { t.Fatalf("second reservation failed: %d", w2.Code) } // Verify only one user reservation exists var count int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1 `, userID).Scan(&count) if err != nil { t.Fatalf("failed to count reservations: %v", err) } if count != 1 { t.Errorf("expected 1 reservation for user, got %d", count) } } // TestReserveSlot_Anonymous verifies anonymous users can reserve a slot. func TestReserveSlot_Anonymous(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } serviceIDs := []string{serviceID} startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) reqBody := ReserveSlotRequest{ StartTime: startTime, ServiceIDs: serviceIDs, } w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, "") if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var response ReserveSlotResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if !response.IsAnonymous { t.Error("expected is_anonymous=true for anonymous user") } } // TestReserveSlot_ValidationErrors verifies that missing or invalid // fields result in 400 Bad Request. func TestReserveSlot_ValidationErrors(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } serviceIDs := []string{serviceID} // Missing start_time w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{ "service_ids": serviceIDs, }, "") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String()) } // Missing service_ids startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{ "start_time": startTime, }, "") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for missing service_ids, got %d. body: %s", w.Code, w.Body.String()) } // Past start_time w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: clock.Now().Add(-1 * time.Hour), ServiceIDs: serviceIDs, }, "") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for past start_time, got %d. body: %s", w.Code, w.Body.String()) } } // TestReserveSlot_BlockedByExistingBooking verifies that reserving a slot // that overlaps an existing booking returns 409 Conflict. func TestReserveSlot_BlockedByExistingBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create a fixture user and booking at the same time userID, _ := fixtures.CreateTestUser(tx) bookingStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, userID, bookingStart) if err != nil { t.Fatalf("failed to create booking: %v", err) } w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: bookingStart, ServiceIDs: []string{serviceID}, }, "") if w.Code != http.StatusConflict { t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestReserveSlot_BlockedByTimeBlocker verifies that reserving a blocked // time slot returns 409 Conflict. func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } blockerStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Admin Blocked', NULL) `, blockerStart) if err != nil { t.Fatalf("failed to create blocker: %v", err) } w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: blockerStart, ServiceIDs: []string{serviceID}, }, "") if w.Code != http.StatusConflict { t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String()) } } // TestReserveSlot_DualCleanup verifies that CleanupOldReservations deletes // anon reservations after 10 minutes and user reservations after 1 hour. func TestReserveSlot_DualCleanup(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create fixture users for user reservations user1ID, _ := fixtures.CreateTestUser(tx) user2ID, _ := fixtures.CreateTestUser(tx) // Create old anon reservation (15 min ago) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL) `, clock.Now().Add(24*time.Hour), clock.Now().Add(-15*time.Minute)) if err != nil { t.Fatalf("failed to create old anon reservation: %v", err) } // Create recent anon reservation (5 min ago) - should survive _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL) `, clock.Now().Add(48*time.Hour), clock.Now().Add(-5*time.Minute)) if err != nil { t.Fatalf("failed to create recent anon reservation: %v", err) } // Create old user reservation (45 min ago) - should survive (> 10min, < 1hr) _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3) `, user1ID), clock.Now().Add(72*time.Hour), clock.Now().Add(-45*time.Minute), user1ID) if err != nil { t.Fatalf("failed to create old user reservation: %v", err) } // Create very old user reservation (2 hours ago) - should be deleted _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3) `, user2ID), clock.Now().Add(96*time.Hour), clock.Now().Add(-2*time.Hour), user2ID) if err != nil { t.Fatalf("failed to create very old user reservation: %v", err) } // Run cleanup err = scheduling.CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old anon was deleted var anonOldCount int tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount) if anonOldCount > 0 { t.Error("expected old anon reservation to be deleted") } // Verify recent anon survived var anonRecentCount int tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount) if anonRecentCount != 1 { t.Error("expected recent anon reservation to survive") } // Verify 45-min user reservation survived var user45Count int tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count) if user45Count != 1 { t.Error("expected 45-min user reservation to survive") } // Verify 2hr user reservation was deleted var user2hrCount int tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount) if user2hrCount > 0 { t.Error("expected 2-hour user reservation to be deleted") } } // seedCustomWorkingHours replaces working_hours with the given schedule. // DB convention: 0=Monday, 1=Tuesday, ..., 6=Sunday. func seedCustomWorkingHours(t *testing.T, ctx context.Context, q db.Querier, hours []struct { weekday int startTime string endTime string isOpen bool }) { t.Helper() for _, h := range hours { _, err := q.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4) ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4 `, h.weekday, h.startTime, h.endTime, h.isOpen) if err != nil { t.Fatalf("failed to seed working hours for weekday %d: %v", h.weekday, err) } } } // nextWeekday returns the next occurrence of the given weekday (0=Sunday..6=Saturday) // in the given location, at least 2 days from now to avoid "in the past" rejections. func nextWeekday(weekday time.Weekday) time.Time { // Use clock.Now() (UTC) so the returned time is consistent with // handler comparisons that use clock.Now() — avoids BST/GMT drift // when the handler checks deposit advance windows or working hours. now := clock.Now() daysAhead := int(weekday) - int(now.Weekday()) if daysAhead <= 0 { daysAhead += 7 } if daysAhead < 2 { daysAhead += 7 } next := now.AddDate(0, 0, daysAhead) return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, time.UTC) } // TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday) // is correctly mapped to the DB's weekday convention (0=Monday). func TestReserveSlot_WeekdayConversion(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) hours := []struct { weekday int startTime string endTime string isOpen bool }{ {0, "09:00", "17:00", true}, {1, "09:00", "17:00", true}, {2, "09:00", "17:00", true}, {3, "09:00", "17:00", true}, {4, "09:00", "17:00", true}, {5, "10:00", "14:00", true}, {6, "00:00", "00:00", false}, } seedCustomWorkingHours(t, ctx, tx, hours) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } monday := nextWeekday(time.Monday) tests := []struct { name string startTime time.Time expectCode int }{ {"Monday 10:00", monday.Add(10 * time.Hour), http.StatusCreated}, {"Tuesday 10:00", monday.AddDate(0, 0, 1).Add(10 * time.Hour), http.StatusCreated}, {"Wednesday 10:00", monday.AddDate(0, 0, 2).Add(10 * time.Hour), http.StatusCreated}, {"Thursday 10:00", monday.AddDate(0, 0, 3).Add(10 * time.Hour), http.StatusCreated}, {"Friday 10:00", monday.AddDate(0, 0, 4).Add(10 * time.Hour), http.StatusCreated}, {"Saturday 11:00", monday.AddDate(0, 0, 5).Add(11 * time.Hour), http.StatusCreated}, {"Sunday 11:00", monday.AddDate(0, 0, 6).Add(11 * time.Hour), http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") if w.Code != tt.expectCode { t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String()) } }) } } // TestReserveSlot_ClosingHoursValidation verifies that bookings extending // past closing time are rejected, and that the correct day's closing time // is used (not the wrong day due to weekday mismatch). func TestReserveSlot_ClosingHoursValidation(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) hours := []struct { weekday int startTime string endTime string isOpen bool }{ {0, "09:00", "17:00", true}, {1, "09:00", "17:00", true}, {2, "09:00", "17:00", true}, {3, "12:00", "20:00", true}, {4, "09:00", "17:00", true}, {5, "10:00", "14:00", true}, {6, "00:00", "00:00", false}, } seedCustomWorkingHours(t, ctx, tx, hours) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } monday := nextWeekday(time.Monday) thursday := monday.AddDate(0, 0, 3) tests := []struct { name string startTime time.Time expectCode int }{ {"Thursday 17:30 (within 20:00 close)", thursday.Add(17*time.Hour + 30*time.Minute), http.StatusCreated}, {"Thursday 19:30 (past 20:00 close)", thursday.Add(19*time.Hour + 30*time.Minute), http.StatusBadRequest}, {"Monday 16:30 (past 17:00 close)", monday.Add(16*time.Hour + 30*time.Minute), http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") if w.Code != tt.expectCode { t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String()) } }) } } // TestReserveSlot_UTCtoLondonConversion verifies that the UTC-to-London // conversion correctly validates closing hours against the London wall-clock. // Test times are in UTC; the handler converts to London time for comparison. func TestReserveSlot_UTCtoLondonConversion(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) hours := []struct { weekday int startTime string endTime string isOpen bool }{ {0, "09:00", "17:00", true}, {1, "09:00", "17:00", true}, {2, "09:00", "17:00", true}, {3, "12:00", "20:00", true}, {4, "09:00", "17:00", true}, {5, "10:00", "14:00", true}, {6, "00:00", "00:00", false}, } seedCustomWorkingHours(t, ctx, tx, hours) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } thursday := nextWeekday(time.Thursday) thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute) thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute) tests := []struct { name string startTime time.Time expectCode int }{ {"17:30 UTC = 18:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated}, {"19:30 UTC = 20:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") if w.Code != tt.expectCode { t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String()) } }) } } // TestReserveSlot_DifferentClosingPerDay verifies that each day's closing // time is used independently — a late-booking on a late-closing day should // succeed while the same time on an early-closing day should fail. // TestReserveSlot_BlockedByExistingBooking_LoggedIn verifies that a logged-in // user's reservation is rejected with 409 when the slot overlaps an existing // booking. This tests the hasAuth transaction path with FOR UPDATE locking. func TestReserveSlot_BlockedByExistingBooking_LoggedIn(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create an existing booking at the same time slot bookingStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, userID, bookingStart) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Try to reserve the same slot as logged-in user — goes through hasAuth tx path w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: bookingStart, ServiceIDs: []string{serviceID}, }, token) if w.Code != http.StatusConflict { t.Errorf("logged-in overlap: expected 409, got %d. body: %s", w.Code, w.Body.String()) } } // TestReserveSlot_AdjacentBooking_Allowed verifies that reserving a slot // adjacent to (but not overlapping) an existing booking is allowed for // both logged-in and anonymous users. This is the negative test for the // overlap check — the FOR UPDATE locking should not block non-conflicting slots. func TestReserveSlot_AdjacentBooking_Allowed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Use a far-future weekday so closing-time and deposit checks pass thursday := nextWeekday(time.Thursday).Add(21 * 24 * time.Hour) bookingStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 10, 0, 0, 0, time.UTC) svcDuration := durationMinutes(t, ctx, tx, serviceID) bookingEnd := bookingStart.Add(time.Duration(svcDuration) * time.Minute) // Create an existing booking at [bookingStart, bookingEnd) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, userID, bookingStart) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } // Try to reserve a slot that starts exactly when the existing booking ends // This is ADJACENT (no overlap) — must be allowed. adjacentStart := bookingEnd adjacentEnd := adjacentStart.Add(time.Duration(svcDuration) * time.Minute) _ = adjacentEnd // for documentation w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: adjacentStart, ServiceIDs: []string{serviceID}, }, token) if w.Code != http.StatusCreated { t.Errorf("logged-in adjacent: expected 201 (adjacent, no overlap), got %d. body: %s", w.Code, w.Body.String()) } // Also test anonymous user with the same adjacent slot // Use a fresh time slot (don't reuse the now-booked one, since an anon // reservation is a time_blocker not a booking, and doesn't conflict) anonStart := adjacentStart.Add(2 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, userID, anonStart) if err != nil { t.Fatalf("failed to create booking for anon test: %v", err) } anonAdjacent := anonStart.Add(time.Duration(svcDuration) * time.Minute) w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: anonAdjacent, ServiceIDs: []string{serviceID}, }, "") if w2.Code != http.StatusCreated { t.Errorf("anon adjacent: expected 201 (adjacent, no overlap), got %d. body: %s", w2.Code, w2.Body.String()) } } func TestReserveSlot_DifferentClosingPerDay(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) hours := []struct { weekday int startTime string endTime string isOpen bool }{ {0, "09:00", "17:00", true}, {1, "09:00", "17:00", true}, {2, "12:00", "17:00", true}, {3, "12:00", "20:00", true}, {4, "09:00", "17:00", true}, {5, "10:00", "14:00", true}, {6, "00:00", "00:00", false}, } seedCustomWorkingHours(t, ctx, tx, hours) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } wednesday := nextWeekday(time.Wednesday) thursday := wednesday.AddDate(0, 0, 1) tests := []struct { name string startTime time.Time expectCode int }{ {"Wednesday 18:00 (closes 17:00)", wednesday.Add(18 * time.Hour), http.StatusBadRequest}, {"Thursday 18:00 (closes 20:00)", thursday.Add(18 * time.Hour), http.StatusCreated}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") if w.Code != tt.expectCode { t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String()) } }) } } // TestReserveSlot_PendingRelease_DoesNotBlock verifies that a pending_release // booking does NOT block the reserve endpoint. pending_release bookings are // evictable — eviction happens at creation time (CreateBookingHandler), not // during the temporary reservation step. func TestReserveSlot_PendingRelease_DoesNotBlock(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Create a pending_release booking at this time slot _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'pending_release', false) `, userID, future) if err != nil { t.Fatalf("failed to create pending_release booking: %v", err) } // Reserve the same slot — should succeed because pending_release // does not block reservations (eviction happens at creation time). w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: future, ServiceIDs: []string{serviceID}, }, token) if w.Code == http.StatusConflict { t.Errorf("pending_release should NOT block reservation – it is evictable at creation time, got 409") } if w.Code != http.StatusCreated { t.Errorf("expected 201 for slot with only pending_release overlap, got %d. body: %s", w.Code, w.Body.String()) } }