diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index 8c76458..44c034d 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -3119,6 +3119,7 @@ func TestAdminApproveEditRequest(t *testing.T) { rctx.URLParams.Add("request_id", editRequestID) reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admintest001") req = req.WithContext(reqCtx) w := httptest.NewRecorder() @@ -3341,6 +3342,7 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) reqCtx := ctx reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admintest001") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) @@ -3615,6 +3617,7 @@ func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) reqCtx := ctx reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admintest001") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) diff --git a/backend/handlers/bookings/overlap_test.go b/backend/handlers/bookings/overlap_test.go index 2a7b1a0..68dde2f 100644 --- a/backend/handlers/bookings/overlap_test.go +++ b/backend/handlers/bookings/overlap_test.go @@ -4,10 +4,13 @@ package bookings import ( + "bytes" "context" + "crypto/md5" "encoding/json" "fmt" "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -18,6 +21,7 @@ import ( "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" + "github.com/go-chi/chi/v5" ) // ============================================================================ @@ -840,12 +844,6 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) { // Use <48h from now so RequestEditHandler does NOT auto-approve nearTime := clock.Now().Add(40 * time.Hour) nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location()) - switch nearTime.Weekday() { - case time.Sunday: - nearTime = nearTime.AddDate(0, 0, 2) - case time.Monday: - nearTime = nearTime.AddDate(0, 0, 1) - } token := jwt.GenerateUserToken(userID) @@ -1746,6 +1744,93 @@ func TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch(t * } } +// TestReserveSlot_DoesNotSelfBlock verifies that calling ReserveSlotHandler +// for the same slot twice (logged-in) does not fail. +func TestReserveSlot_DoesNotSelfBlock(t *testing.T) { + t.Parallel() + 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 service: %v", err) + } + + future := weekdayTime(time.Monday, 10) + + w := makeRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve", + &ReserveSlotRequest{ + StartTime: future, + ServiceIDs: []string{serviceID}, + }, token, ctx) + + if w.Code != http.StatusCreated { + t.Fatalf("first reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) + } + + w = makeRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve", + &ReserveSlotRequest{ + StartTime: future, + ServiceIDs: []string{serviceID}, + }, token, ctx) + + if w.Code == http.StatusConflict { + t.Fatalf("second reserve: self-blocked (got 409) — ReserveSlotHandler should not self-block. body: %s", w.Body.String()) + } + if w.Code != http.StatusCreated { + t.Fatalf("second reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminReserveSlot_DoesNotSelfBlock verifies that calling AdminReserveSlotHandler +// for the same slot twice does not fail. +func TestAdminReserveSlot_DoesNotSelfBlock(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // Create an admin user in the DB so the foreign key constraint is satisfied + adminID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + _, err = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + if err != nil { + t.Fatalf("failed to set admin role: %v", err) + } + adminToken := jwt.GenerateTestToken(adminID, "admin") + + future := weekdayTime(time.Monday, 11) + + reqBody := AdminReserveSlotRequest{ + StartTime: future, + DurationMinutes: 60, + ReservationType: "walkin", + TTLMinutes: 15, + } + + w := makeRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve", + reqBody, adminToken, ctx) + + if w.Code != http.StatusCreated { + t.Fatalf("first admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) + } + + w = makeRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve", + reqBody, adminToken, ctx) + + if w.Code == http.StatusConflict { + t.Fatalf("second admin reserve: self-blocked (got 409) — AdminReserveSlotHandler should not self-block. body: %s", w.Body.String()) + } + if w.Code != http.StatusCreated { + t.Fatalf("second admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) + } +} + // TestAdminApproveEditRequest_EvictsPendingRelease verifies that approving an // edit request evicts overlapping pending_release bookings at the new time slot. func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) { @@ -1771,11 +1856,6 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) { // Use a booking <48h from now so RequestEdit does NOT auto-approve nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second) nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), 10, 0, 0, 0, nearTime.Location()) - if nearTime.Weekday() == time.Sunday { - nearTime = nearTime.AddDate(0, 0, 2) - } else if nearTime.Weekday() == time.Monday { - nearTime = nearTime.AddDate(0, 0, 1) - } bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime) if err != nil { @@ -1836,3 +1916,84 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) { t.Errorf("expected pending_release to be evicted to 'deposit_lapsed', got %q", newStatus) } } + +// TestReserveSlot_CleansUpAnonReservation verifies that ReserveSlotHandler +// cleans up anonymous RESERVATION:anon entries matching the user's IP +// when the user is authenticated (IP hash matching in pre-overlap DELETE). +func TestReserveSlot_CleansUpAnonReservation(t *testing.T) { + t.Parallel() + 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 service: %v", err) + } + + future := weekdayTime(time.Monday, 10) + + // Set a known IP that the test request will use + testIP := "192.0.2.1" + ipHash := fmt.Sprintf("%x", md5.Sum([]byte(testIP)))[:8] + + // Create an anonymous reservation (created_by = NULL) matching this IP hash + var blockerID string + err = tx.QueryRow(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, $2, $3, NULL) + RETURNING id + `, future, 60, fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())).Scan(&blockerID) + if err != nil { + t.Fatalf("failed to create anon reservation: %v", err) + } + + // Call ReserveSlotHandler with CF-Connecting-IP header set to match the anon reservation + makeIPRequest := func(handler http.Handler, method, path string, body interface{}, token string, ip string, requestCtx ...context.Context) *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) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("CF-Connecting-IP", ip) + + baseCtx := req.Context() + if len(requestCtx) > 0 { + baseCtx = requestCtx[0] + } + rctx := chi.NewRouteContext() + ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w + } + + // First call should succeed — creates RESERVATION:user entry + w := makeIPRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve", + &ReserveSlotRequest{ + StartTime: future, + ServiceIDs: []string{serviceID}, + }, token, testIP, ctx) + + if w.Code != http.StatusCreated { + t.Fatalf("first reserve: expected 201, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify the anon reservation was cleaned up by the pre-overlap DELETE + var remaining int + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining) + if remaining != 0 { + t.Errorf("expected anonymous reservation to be cleaned up by pre-overlap DELETE, got %d remaining", remaining) + } +}