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>
771 lines
25 KiB
Go
771 lines
25 KiB
Go
//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())
|
||
}
|
||
}
|