Files
Crussell/backend/handlers/bookings/reserve_test.go
T
2026-06-18 16:26:22 +01:00

642 lines
20 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/db"
"crussell/handlers/scheduling"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func resetReserveTestData(t *testing.T) {
t.Helper()
testdb.TruncateTables(t, db.DB)
seedDefaultWorkingHours(t)
}
func makeReserveRequest(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 user context from token if present
if token != "" {
if info := extractUserFromTestJWT(token); info != nil {
rctx := chi.NewRouteContext()
ctx := context.WithValue(req.Context(), 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) {
resetReserveTestData(t)
userID, _ := fixtures.CreateTestUser(db.DB)
token := reserveTestToken(t, userID, "verified_email")
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
reqBody := ReserveSlotRequest{
StartTime: startTime,
ServiceIDs: []string{serviceID},
}
w := makeReserveRequest("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) {
resetReserveTestData(t)
userID, _ := fixtures.CreateTestUser(db.DB)
token := reserveTestToken(t, userID, "verified_email")
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
serviceIDs := []string{serviceID}
// First reservation
startTime1 := time.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
w1 := makeReserveRequest("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 := time.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
w2 := makeReserveRequest("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 = db.DB.QueryRow(context.Background(), `
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) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
serviceIDs := []string{serviceID}
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
reqBody := ReserveSlotRequest{
StartTime: startTime,
ServiceIDs: serviceIDs,
}
w := makeReserveRequest("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) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
serviceIDs := []string{serviceID}
// Missing start_time
w := makeReserveRequest("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 := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
w = makeReserveRequest("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("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: time.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) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
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(db.DB)
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = db.DB.Exec(context.Background(), `
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("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) {
resetReserveTestData(t)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = db.DB.Exec(context.Background(), `
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("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) {
resetReserveTestData(t)
ctx := context.Background()
// Create fixture users for user reservations
user1ID, _ := fixtures.CreateTestUser(db.DB)
user2ID, _ := fixtures.CreateTestUser(db.DB)
// Create old anon reservation (15 min ago)
_, err := db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL)
`, time.Now().Add(24*time.Hour), time.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 = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL)
`, time.Now().Add(48*time.Hour), time.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 = db.DB.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), time.Now().Add(72*time.Hour), time.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 = db.DB.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), time.Now().Add(96*time.Hour), time.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
db.DB.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
db.DB.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
db.DB.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
db.DB.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, hours []struct {
weekday int
startTime string
endTime string
isOpen bool
}) {
t.Helper()
for _, h := range hours {
_, err := db.DB.Exec(context.Background(), `
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, loc *time.Location) time.Time {
now := time.Now().In(loc)
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, next.Location())
}
// 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) {
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},
}
testdb.TruncateTables(t, db.DB)
seedCustomWorkingHours(t, hours)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
monday := nextWeekday(time.Monday, london)
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("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) {
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},
}
testdb.TruncateTables(t, db.DB)
seedCustomWorkingHours(t, hours)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
monday := nextWeekday(time.Monday, london)
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("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 a UTC timestamp sent
// from the browser is correctly interpreted as London local time for the
// purpose of working hours lookup.
func TestReserveSlot_UTCtoLondonConversion(t *testing.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},
}
testdb.TruncateTables(t, db.DB)
seedCustomWorkingHours(t, hours)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
thursday := nextWeekday(time.Thursday, london)
// Convert to UTC for the request (frontend sends UTC)
thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute).In(london).UTC()
thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute).In(london).UTC()
tests := []struct {
name string
startTime time.Time
expectCode int
}{
{"17:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
{"19:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := makeReserveRequest("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.
func TestReserveSlot_DifferentClosingPerDay(t *testing.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},
}
testdb.TruncateTables(t, db.DB)
seedCustomWorkingHours(t, hours)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
wednesday := nextWeekday(time.Wednesday, london)
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("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())
}
})
}
}