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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user