test: add tests for reservation self-block prevention

Add TestReserveSlot_DoesNotSelfBlock, TestAdminReserveSlot_DoesNotSelfBlock and TestReserveSlot_CleansUpAnonReservation. Add UserIDKey context to admin approve edit request tests. Remove weekend-day adjustment in TestAdminApproveEditRequest_OverlapWithBooking_Regression and TestAdminApproveEditRequest_EvictsPendingRelease (no longer needed).

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-07-05 11:47:43 +01:00
co-authored by Sisyphus
parent c1a9f35ea5
commit 2386bd0ca2
2 changed files with 175 additions and 11 deletions
@@ -3119,6 +3119,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
rctx.URLParams.Add("request_id", editRequestID) rctx.URLParams.Add("request_id", editRequestID)
reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admintest001")
req = req.WithContext(reqCtx) req = req.WithContext(reqCtx)
w := httptest.NewRecorder() 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) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
reqCtx := ctx reqCtx := ctx
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admintest001")
rctx := chi.NewRouteContext() rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID) 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) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
reqCtx := ctx reqCtx := ctx
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admintest001")
rctx := chi.NewRouteContext() rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID) rctx.URLParams.Add("request_id", editRequestID)
+172 -11
View File
@@ -4,10 +4,13 @@
package bookings package bookings
import ( import (
"bytes"
"context" "context"
"crypto/md5"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -18,6 +21,7 @@ import (
"crussell/testutils" "crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "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 // Use <48h from now so RequestEditHandler does NOT auto-approve
nearTime := clock.Now().Add(40 * time.Hour) nearTime := clock.Now().Add(40 * time.Hour)
nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location()) 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) 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 // TestAdminApproveEditRequest_EvictsPendingRelease verifies that approving an
// edit request evicts overlapping pending_release bookings at the new time slot. // edit request evicts overlapping pending_release bookings at the new time slot.
func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) { 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 // Use a booking <48h from now so RequestEdit does NOT auto-approve
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second) 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()) 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) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
if err != nil { 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) 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)
}
}