feat: wire reserve route, dual TTL cleanup, and booking reservation deletion

Add POST /api/bookings/reserve route with OptionalAuth middleware and rate limiting.
Update CleanupOldReservations to handle dual TTLs: 1hr for logged-in users,
10min for anonymous reservations. Update CreateBookingHandler to also match
anon reservations by start_time for users who register mid-flow.
Add comprehensive tests for reservation creation, validation, conflict detection, and cleanup.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-29 23:05:48 +01:00
co-authored by Sisyphus
parent 2ba7b85d0b
commit 6766c6128c
4 changed files with 421 additions and 32 deletions
+6 -5
View File
@@ -3,10 +3,10 @@ package bookings
import (
"crussell/db"
"crussell/handlers/notifications"
"crussell/handlers/scheduling"
"crussell/internal/dav"
"crussell/internal/validators"
"crussell/mw"
"crussell/handlers/scheduling"
"database/sql"
"encoding/json"
"errors"
@@ -1230,10 +1230,13 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
defer tx.Rollback(r.Context())
// Delete any existing reservation for this user (max 1 per user)
// Also matches anon reservations by start_time for users who register mid-flow
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE created_by = $1 AND description LIKE 'RESERVATION:%'
`, userID)
WHERE description LIKE 'RESERVATION:%'
AND (created_by = $1
OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
`, userID, req.StartTime)
// Insert booking with snapshotted deposit_required
var booking Booking
@@ -2231,8 +2234,6 @@ END:VEVENT
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status)
}
// OverlappingBooking represents a booking that overlaps with another
type OverlappingBooking struct {
ID string `json:"id"`
+379
View File
@@ -0,0 +1,379 @@
//go:build test
// +build test
package bookings
import (
"bytes"
"context"
"encoding/json"
"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 setupReserveTestDB(t *testing.T) func() {
t.Helper()
pool := testdb.Pool(t)
testdb.Migrate(t, pool)
testdb.TruncateTables(t, pool)
originalDB := db.DB
db.DB = pool
jwt.Init()
seedDefaultWorkingHours(t)
return func() {
db.DB = originalDB
pool.Close()
}
}
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()
token, err := jwt.GenerateTestJWT(userID, role, time.Hour)
if err != nil {
t.Fatalf("failed to generate test JWT: %v", err)
}
return token
}
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
func TestReserveSlot_LoggedIn(t *testing.T) {
cleanup := setupReserveTestDB(t)
defer cleanup()
token := reserveTestToken(t, "user-001", "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: serviceIDs,
}
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) {
cleanup := setupReserveTestDB(t)
defer cleanup()
token := reserveTestToken(t, "user-002", "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 = 'user-002'
`).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) {
cleanup := setupReserveTestDB(t)
defer cleanup()
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) {
cleanup := setupReserveTestDB(t)
defer cleanup()
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) {
cleanup := setupReserveTestDB(t)
defer cleanup()
// Create a booking at the same time
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)
`, "fixture-user", bookingStart)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: bookingStart,
ServiceIDs: serviceIDs,
}, "")
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) {
cleanup := setupReserveTestDB(t)
defer cleanup()
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: serviceIDs,
}, "")
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) {
cleanup := setupReserveTestDB(t)
defer cleanup()
ctx := context.Background()
// 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, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:user:user-001:1234', $2, 'user-001')
`, time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute))
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, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:user:user-002:1234', $2, 'user-002')
`, time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour))
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 'RESERVATION:user:user-001:%'`).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 'RESERVATION:user:user-002:%'`).Scan(&user2hrCount)
if user2hrCount > 0 {
t.Error("expected 2-hour user reservation to be deleted")
}
}
+8 -6
View File
@@ -185,7 +185,6 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// --- Helper: Get Time Blockers in Range ---
// --- Helper: Get Time Blockers in Range ---
@@ -334,14 +333,17 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time)
return false, "", nil
}
// CleanupOldReservations deletes reservations (time_blockers with RESERVATION: description prefix)
// that are older than 1 hour.
// CleanupOldReservations deletes expired reservations:
// - Logged-in (RESERVATION:user): older than 1 hour
// - Anonymous (RESERVATION:anon): older than 10 minutes
func CleanupOldReservations(ctx context.Context) error {
oneHourAgo := time.Now().Add(-1 * time.Hour)
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
_, err := db.DB.Exec(ctx, `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:%'
AND created_at < $1
`, oneHourAgo)
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
`, oneHourAgo, tenMinutesAgo)
return err
}
+7
View File
@@ -137,6 +137,13 @@ func main() {
})
})
// Public booking endpoints (optional auth for slot reservation)
r.Route("/bookings", func(r chi.Router) {
r.Use(mw.RateLimit(30, time.Minute))
r.Use(mw.OptionalAuth)
r.Post("/reserve", bookings.ReserveSlotHandler)
})
// Authenticated users
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)