diff --git a/src/app/[locale]/admin/bookings-test/BookingAdminTestComponent.tsx b/src/app/[locale]/admin/bookings-test/BookingAdminTestComponent.tsx new file mode 100644 index 0000000..1867539 --- /dev/null +++ b/src/app/[locale]/admin/bookings-test/BookingAdminTestComponent.tsx @@ -0,0 +1,848 @@ +'use client'; + +import { useState } from 'react'; +import { Calendar, Loader2, AlertCircle, CheckCircle2, XCircle } from 'lucide-react'; +import { + getBookingDetail, + cancelBooking, + moveBooking, + updateAttendees, + generateIdempotencyKey, + canModifyBooking, + getProviderGatingMessage, +} from '@/src/lib/api/booking-admin'; +import type { + BookingDetail, + CancelBookingRequest, + MoveBookingRequest, + UpdateAttendeesRequest, + AttendeeInput, +} from '@/src/types/booking-admin'; +import { ERROR_MESSAGES } from '@/src/types/booking-admin'; + +export default function BookingAdminTestComponent() { + const [bookingId, setBookingId] = useState(''); + const [booking, setBooking] = useState(null); + const [etag, setEtag] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Test results + const [testResults, setTestResults] = useState>([]); + + // Form state for operations + const [cancelReason, setCancelReason] = useState('Court maintenance - full refund issued'); + const [moveTargetSlotId, setMoveTargetSlotId] = useState(''); + const [moveReason, setMoveReason] = useState('Original slot cancelled for tournament'); + const [attendeesJson, setAttendeesJson] = useState(''); + + async function loadBooking() { + if (!bookingId.trim()) { + setError('Please enter a booking ID'); + return; + } + + setLoading(true); + setError(null); + + const result = await getBookingDetail(parseInt(bookingId, 10), { + expand: ['slot'], + include_provider: true, + }); + + if (result.success) { + setBooking(result.data); + setEtag(result.etag || null); + setError(null); + + // Initialize attendees JSON + setAttendeesJson(JSON.stringify(result.data.attendees, null, 2)); + } else { + setError(result.error.detail); + setBooking(null); + } + + setLoading(false); + } + + function addTestResult(scenario: string, status: 'success' | 'error', message: string, request: any, response: any) { + setTestResults(prev => [ + ...prev, + { + scenario, + status, + message, + timestamp: new Date().toISOString(), + request, + response, + }, + ]); + } + + // ======================================== + // Scenario 1: Cancel within grace (≤15 min) + // ======================================== + async function testCancelWithinGrace() { + if (!booking) return; + + const idempotencyKey = generateIdempotencyKey(); + const request: CancelBookingRequest = { + status: 'cancelled', + reason: cancelReason, + notify_players: true, + }; + + const result = await cancelBooking(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'Cancel within grace (≤15 min)', + 'success', + 'Booking cancelled successfully', + { bookingId: booking.booking_id, request, idempotencyKey }, + result.data + ); + // Reload booking + await loadBooking(); + } else { + addTestResult( + 'Cancel within grace (≤15 min)', + 'error', + result.error.detail, + { bookingId: booking.booking_id, request, idempotencyKey }, + result.error + ); + } + } + + // ======================================== + // Scenario 2: Cancel beyond grace (>15 min) + // ======================================== + async function testCancelBeyondGrace() { + if (!booking) return; + + // This should fail with past_slot_locked error + const idempotencyKey = generateIdempotencyKey(); + const request: CancelBookingRequest = { + status: 'cancelled', + reason: 'Testing cancel beyond grace period', + notify_players: true, + }; + + const result = await cancelBooking(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'Cancel beyond grace (>15 min)', + 'success', + 'Booking cancelled (unexpected if beyond grace)', + { bookingId: booking.booking_id, request, idempotencyKey }, + result.data + ); + } else { + addTestResult( + 'Cancel beyond grace (>15 min)', + result.error.code === 'past_slot_locked' ? 'success' : 'error', + result.error.code === 'past_slot_locked' + ? 'Correctly rejected: past_slot_locked' + : `Unexpected error: ${result.error.detail}`, + { bookingId: booking.booking_id, request, idempotencyKey }, + result.error + ); + } + } + + // ======================================== + // Scenario 3: Move dry_run success + // ======================================== + async function testMoveDryRunSuccess() { + if (!booking || !moveTargetSlotId.trim()) { + setError('Please enter a target slot ID'); + return; + } + + const request: MoveBookingRequest = { + new_slot_instance_id: parseInt(moveTargetSlotId, 10), + reason: moveReason, + notify_players: true, + dry_run: true, + }; + + const result = await moveBooking(booking.booking_id, request, { + etag: etag || undefined, + }); + + if (result.success) { + const dryRunResult = result.data as any; + addTestResult( + 'Move dry_run success', + dryRunResult.ok ? 'success' : 'error', + dryRunResult.ok + ? 'dry_run validation passed' + : `dry_run validation failed: ${dryRunResult.reasons?.join(', ')}`, + { bookingId: booking.booking_id, request }, + result.data + ); + } else { + addTestResult( + 'Move dry_run success', + 'error', + result.error.detail, + { bookingId: booking.booking_id, request }, + result.error + ); + } + } + + // ======================================== + // Scenario 4: Move dry_run failure + // ======================================== + async function testMoveDryRunFailure() { + if (!booking) return; + + // Use a non-existent or incompatible slot ID to trigger failure + const request: MoveBookingRequest = { + new_slot_instance_id: 999999, // Non-existent slot + reason: 'Testing dry_run failure', + notify_players: true, + dry_run: true, + }; + + const result = await moveBooking(booking.booking_id, request, { + etag: etag || undefined, + }); + + if (result.success) { + const dryRunResult = result.data as any; + addTestResult( + 'Move dry_run failure', + !dryRunResult.ok ? 'success' : 'error', + !dryRunResult.ok + ? `Correctly failed: ${dryRunResult.reasons?.join(', ')}` + : 'Unexpected success (should have failed)', + { bookingId: booking.booking_id, request }, + result.data + ); + } else { + addTestResult( + 'Move dry_run failure', + 'success', + `Correctly rejected: ${result.error.code}`, + { bookingId: booking.booking_id, request }, + result.error + ); + } + } + + // ======================================== + // Scenario 5: Move window exceeded (>14 days) + // ======================================== + async function testMoveWindowExceeded() { + if (!booking || !moveTargetSlotId.trim()) { + setError('Please enter a target slot ID >14 days away'); + return; + } + + const idempotencyKey = generateIdempotencyKey(); + const request: MoveBookingRequest = { + new_slot_instance_id: parseInt(moveTargetSlotId, 10), + reason: 'Testing move window exceeded', + notify_players: true, + dry_run: false, + }; + + const result = await moveBooking(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'Move window exceeded (>14 days)', + 'error', + 'Unexpected success (should have been blocked)', + { bookingId: booking.booking_id, request, idempotencyKey }, + result.data + ); + } else { + addTestResult( + 'Move window exceeded (>14 days)', + result.error.code === 'move_window_exceeded' ? 'success' : 'error', + result.error.code === 'move_window_exceeded' + ? 'Correctly rejected: move_window_exceeded' + : `Unexpected error: ${result.error.detail}`, + { bookingId: booking.booking_id, request, idempotencyKey }, + result.error + ); + } + } + + // ======================================== + // Scenario 6: Attendee updates with capacity limits + // ======================================== + async function testAttendeeCapacityLimits() { + if (!booking) return; + + const idempotencyKey = generateIdempotencyKey(); + + // Try to add more attendees than capacity allows + const attendees: AttendeeInput[] = []; + const capacity = booking.slot.capacity; + + // Add capacity + 1 attendees (should fail) + for (let i = 1; i <= capacity + 1; i++) { + attendees.push({ + position: i, + type: 'guest', + display_name: `Guest ${i}`, + }); + } + + const request: UpdateAttendeesRequest = { + attendees, + reason: 'Testing capacity limits', + notify_players: true, + }; + + const result = await updateAttendees(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'Attendee updates with capacity limits', + 'error', + 'Unexpected success (should have exceeded capacity)', + { bookingId: booking.booking_id, request, idempotencyKey }, + result.data + ); + } else { + addTestResult( + 'Attendee updates with capacity limits', + result.error.code === 'exceeds_capacity' ? 'success' : 'error', + result.error.code === 'exceeds_capacity' + ? 'Correctly rejected: exceeds_capacity' + : `Unexpected error: ${result.error.detail}`, + { bookingId: booking.booking_id, request, idempotencyKey }, + result.error + ); + } + } + + // ======================================== + // Scenario 7: Capacity race (concurrent moves) + // ======================================== + async function testCapacityRace() { + if (!booking || !moveTargetSlotId.trim()) { + setError('Please enter a target slot ID'); + return; + } + + // Execute two concurrent move requests to the same slot + const idempotencyKey1 = generateIdempotencyKey(); + const idempotencyKey2 = generateIdempotencyKey(); + + const request: MoveBookingRequest = { + new_slot_instance_id: parseInt(moveTargetSlotId, 10), + reason: 'Testing capacity race', + notify_players: true, + dry_run: false, + }; + + // Execute both requests concurrently + const [result1, result2] = await Promise.all([ + moveBooking(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey: idempotencyKey1, + }), + moveBooking(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey: idempotencyKey2, + }), + ]); + + // One should succeed, one should fail with idempotency_conflict or capacity_conflict + const success1 = result1.success; + const success2 = result2.success; + + addTestResult( + 'Capacity race (concurrent moves) - Request 1', + success1 ? 'success' : 'error', + success1 ? 'First request succeeded' : `First request failed: ${(result1 as any).error.code}`, + { bookingId: booking.booking_id, request, idempotencyKey: idempotencyKey1 }, + success1 ? result1.data : (result1 as any).error + ); + + addTestResult( + 'Capacity race (concurrent moves) - Request 2', + !success2 ? 'success' : 'error', + !success2 + ? `Second request correctly rejected: ${(result2 as any).error.code}` + : 'Second request succeeded (unexpected)', + { bookingId: booking.booking_id, request, idempotencyKey: idempotencyKey2 }, + success2 ? result2.data : (result2 as any).error + ); + } + + // ======================================== + // Scenario 8: ETag guard (stale If-Match) + // ======================================== + async function testETagGuard() { + if (!booking) return; + + const idempotencyKey = generateIdempotencyKey(); + const request: CancelBookingRequest = { + status: 'cancelled', + reason: 'Testing ETag guard', + notify_players: true, + }; + + // Use a stale/incorrect ETag + const staleETag = 'W/"stale-etag-12345"'; + + const result = await cancelBooking(booking.booking_id, request, { + etag: staleETag, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'ETag guard (stale If-Match)', + 'error', + 'Unexpected success (should have been blocked by stale ETag)', + { bookingId: booking.booking_id, request, idempotencyKey, etag: staleETag }, + result.data + ); + } else { + addTestResult( + 'ETag guard (stale If-Match)', + result.error.code === 'precondition_failed' ? 'success' : 'error', + result.error.code === 'precondition_failed' + ? 'Correctly rejected: precondition_failed' + : `Unexpected error: ${result.error.detail}`, + { bookingId: booking.booking_id, request, idempotencyKey, etag: staleETag }, + result.error + ); + } + } + + // ======================================== + // Execute move (non-dry-run) + // ======================================== + async function executeMove() { + if (!booking || !moveTargetSlotId.trim()) { + setError('Please enter a target slot ID'); + return; + } + + const idempotencyKey = generateIdempotencyKey(); + const request: MoveBookingRequest = { + new_slot_instance_id: parseInt(moveTargetSlotId, 10), + reason: moveReason, + notify_players: true, + dry_run: false, + }; + + const result = await moveBooking(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'Move booking (actual)', + 'success', + 'Booking moved successfully', + { bookingId: booking.booking_id, request, idempotencyKey }, + result.data + ); + // Reload booking + await loadBooking(); + } else { + addTestResult( + 'Move booking (actual)', + 'error', + result.error.detail, + { bookingId: booking.booking_id, request, idempotencyKey }, + result.error + ); + } + } + + // ======================================== + // Execute attendees update + // ======================================== + async function executeAttendeesUpdate() { + if (!booking || !attendeesJson.trim()) { + setError('Please enter attendees JSON'); + return; + } + + try { + const attendees: AttendeeInput[] = JSON.parse(attendeesJson); + + const idempotencyKey = generateIdempotencyKey(); + const request: UpdateAttendeesRequest = { + attendees, + reason: 'Manual attendee update', + notify_players: true, + }; + + const result = await updateAttendees(booking.booking_id, request, { + etag: etag || undefined, + idempotencyKey, + }); + + if (result.success) { + addTestResult( + 'Update attendees (actual)', + 'success', + 'Attendees updated successfully', + { bookingId: booking.booking_id, request, idempotencyKey }, + result.data + ); + // Reload booking + await loadBooking(); + } else { + addTestResult( + 'Update attendees (actual)', + 'error', + result.error.detail, + { bookingId: booking.booking_id, request, idempotencyKey }, + result.error + ); + } + } catch (err) { + setError('Invalid JSON format for attendees'); + } + } + + // ======================================== + // Clear test results + // ======================================== + function clearTestResults() { + setTestResults([]); + } + + return ( +
+ {/* Header */} +
+

+ Booking Admin API Test Page +

+

+ Execute 8-scenario E2E test grid for idempotency validation +

+
+

+ Instructions: Open browser DevTools Network tab → Enable "Preserve log" → Execute scenarios → Export HARs for each scenario +

+
+
+ + {/* Load Booking Section */} +
+

1. Load Booking

+
+ setBookingId(e.target.value)} + placeholder="Enter booking ID" + className="flex-1 px-4 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900" + /> + +
+ {error && ( +
+ {error} +
+ )} +
+ + {/* Booking Details */} + {booking && ( +
+

Booking Details

+
+
+
Booking ID
+
{booking.booking_id}
+
+
+
Status
+
{booking.status}
+
+
+
Slot Time
+
+ {new Date(booking.slot.starts_at).toLocaleString()} - {new Date(booking.slot.ends_at).toLocaleTimeString()} +
+
+
+
Court
+
{booking.slot.court.name} (Club: {booking.slot.court.club_name})
+
+
+
Capacity
+
+ {booking.slot.booked_count} / {booking.slot.capacity} booked · {booking.slot.capacity_remaining} remaining +
+
+
+
Provider
+
{booking.provider.type} (manages_slot_storage: {booking.provider.manages_slot_storage ? 'true' : 'false'})
+
+
+
ETag
+
{etag || 'None'}
+
+
+
Policies
+
+ Grace: {booking.policies.cancel_grace_minutes}min, Window: {booking.policies.move_window_days}days +
+
+
+ + {/* Provider Gating Message */} + {!canModifyBooking(booking) && ( +
+
{getProviderGatingMessage(booking)}
+
+ )} + + {/* Attendees */} +
+
Attendees ({booking.attendees.length})
+
+ {booking.attendees.map(att => ( +
+ {att.position}. {att.display_name} ({att.type}) +
+ ))} +
+
+
+ )} + + {/* Test Scenarios */} + {booking && canModifyBooking(booking) && ( + <> +
+

2. Execute Test Scenarios

+ +
+ {/* Cancel Section */} +
+

Cancel Operations

+
+ + setCancelReason(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900" + /> +
+
+ + + +
+
+ + {/* Move Section */} +
+

Move Operations

+
+
+ + setMoveTargetSlotId(e.target.value)} + placeholder="Enter slot instance ID" + className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900" + /> +
+
+ + setMoveReason(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900" + /> +
+
+
+ + + + + +
+
+ + {/* Attendees Section */} +
+

Attendee Operations

+
+ +