//go:build test // +build test 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.RealIP) 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 } return now.AddDate(0, 0, daysAhead).Truncate(24 * time.Hour) } // 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()) } }) } }