Files
Crussell/backend/handlers/bookings/reserve_test.go
T
popertotsandSisyphus e86248b27c refactor: move auth, GDPR, and booking cleanup to centralized scheduler
Convert CleanupRevokedJTIs to return (int, error) and remove StartJTICleanup goroutine. Add CleanupStaleLoginEntries and CleanupGDPRExportCache for centralized scheduler. Add clock.London timezone location.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-07 00:10:00 +01:00

771 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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())
}
}