Fix: Update permissions and add missing /api/exchange/slots endpoint
- Updated auth-context.tsx to use new exchange permissions (CREATE_EXCHANGE, VIEW_OWN_EXCHANGES, etc.) instead of old appointment permissions (BOOK_APPOINTMENT, etc.) - Updated exchange/page.tsx, trades/page.tsx, admin/trades/page.tsx to use correct permission constants - Updated profile/page.test.tsx mock permissions - Updated admin/availability/page.tsx to use constants.exchange instead of constants.booking - Added /api/exchange/slots endpoint to return available slots for a date, filtering out already booked slots - Fixed E2E tests: - exchange.spec.ts: Wait for button to be enabled before clicking - permissions.spec.ts: Use more specific heading selector - price-history.spec.ts: Expect /exchange redirect for regular users
This commit is contained in:
parent
65ba4dc42a
commit
1008eea2d9
11 changed files with 184 additions and 379 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from datetime import UTC, date, datetime, time, timedelta
|
from datetime import UTC, date, datetime, time, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, desc, select
|
from sqlalchemy import and_, desc, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -78,6 +78,20 @@ class ExchangePriceResponse(BaseModel):
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BookableSlot(BaseModel):
|
||||||
|
"""A single bookable time slot."""
|
||||||
|
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableSlotsResponse(BaseModel):
|
||||||
|
"""Response containing available slots for a date."""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
slots: list[BookableSlot]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Helper functions
|
# Helper functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -266,6 +280,86 @@ async def get_exchange_price(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Available Slots Endpoint
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_availability_to_slots(
|
||||||
|
avail: Availability, slot_date: date, booked_starts: set[datetime]
|
||||||
|
) -> list[BookableSlot]:
|
||||||
|
"""
|
||||||
|
Expand an availability block into individual slots, filtering out booked ones.
|
||||||
|
"""
|
||||||
|
slots: list[BookableSlot] = []
|
||||||
|
|
||||||
|
# Start from the availability's start time
|
||||||
|
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
|
||||||
|
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
|
||||||
|
|
||||||
|
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
|
||||||
|
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||||
|
|
||||||
|
# Only include if not already booked
|
||||||
|
if current_start not in booked_starts:
|
||||||
|
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
|
||||||
|
|
||||||
|
current_start = slot_end
|
||||||
|
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/slots", response_model=AvailableSlotsResponse)
|
||||||
|
async def get_available_slots(
|
||||||
|
date_param: date = Query(..., alias="date"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)),
|
||||||
|
) -> AvailableSlotsResponse:
|
||||||
|
"""
|
||||||
|
Get available booking slots for a specific date.
|
||||||
|
|
||||||
|
Returns all slots that:
|
||||||
|
- Fall within admin-defined availability windows
|
||||||
|
- Are not already booked by another user
|
||||||
|
"""
|
||||||
|
validate_date_in_range(date_param, context="book")
|
||||||
|
|
||||||
|
# Get availability for the date
|
||||||
|
result = await db.execute(
|
||||||
|
select(Availability).where(Availability.date == date_param)
|
||||||
|
)
|
||||||
|
availabilities = result.scalars().all()
|
||||||
|
|
||||||
|
if not availabilities:
|
||||||
|
return AvailableSlotsResponse(date=date_param, slots=[])
|
||||||
|
|
||||||
|
# Get already booked slots for the date
|
||||||
|
date_start = datetime.combine(date_param, time.min, tzinfo=UTC)
|
||||||
|
date_end = datetime.combine(date_param, time.max, tzinfo=UTC)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Exchange.slot_start).where(
|
||||||
|
and_(
|
||||||
|
Exchange.slot_start >= date_start,
|
||||||
|
Exchange.slot_start <= date_end,
|
||||||
|
Exchange.status == ExchangeStatus.BOOKED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
booked_starts = {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
# Expand each availability into slots
|
||||||
|
all_slots: list[BookableSlot] = []
|
||||||
|
for avail in availabilities:
|
||||||
|
slots = _expand_availability_to_slots(avail, date_param, booked_starts)
|
||||||
|
all_slots.extend(slots)
|
||||||
|
|
||||||
|
# Sort by start time
|
||||||
|
all_slots.sort(key=lambda s: s.start_time)
|
||||||
|
|
||||||
|
return AvailableSlotsResponse(date=date_param, slots=all_slots)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Create Exchange Endpoint
|
# Create Exchange Endpoint
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
modalStyles,
|
modalStyles,
|
||||||
} from "../../styles/shared";
|
} from "../../styles/shared";
|
||||||
|
|
||||||
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking;
|
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.exchange;
|
||||||
|
|
||||||
type _AvailabilityDay = components["schemas"]["AvailabilityDay"];
|
type _AvailabilityDay = components["schemas"]["AvailabilityDay"];
|
||||||
type AvailabilityResponse = components["schemas"]["AvailabilityResponse"];
|
type AvailabilityResponse = components["schemas"]["AvailabilityResponse"];
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ type Tab = "upcoming" | "past";
|
||||||
|
|
||||||
export default function AdminTradesPage() {
|
export default function AdminTradesPage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredPermission: Permission.VIEW_ALL_APPOINTMENTS,
|
requiredPermission: Permission.VIEW_ALL_EXCHANGES,
|
||||||
fallbackRedirect: "/",
|
fallbackRedirect: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,15 @@ export const Permission: Record<string, PermissionType> = {
|
||||||
MANAGE_OWN_PROFILE: "manage_own_profile",
|
MANAGE_OWN_PROFILE: "manage_own_profile",
|
||||||
MANAGE_INVITES: "manage_invites",
|
MANAGE_INVITES: "manage_invites",
|
||||||
VIEW_OWN_INVITES: "view_own_invites",
|
VIEW_OWN_INVITES: "view_own_invites",
|
||||||
// Booking permissions (regular users)
|
// Exchange permissions (regular users)
|
||||||
BOOK_APPOINTMENT: "book_appointment",
|
CREATE_EXCHANGE: "create_exchange",
|
||||||
VIEW_OWN_APPOINTMENTS: "view_own_appointments",
|
VIEW_OWN_EXCHANGES: "view_own_exchanges",
|
||||||
CANCEL_OWN_APPOINTMENT: "cancel_own_appointment",
|
CANCEL_OWN_EXCHANGE: "cancel_own_exchange",
|
||||||
// Availability/Appointments permissions (admin)
|
// Availability/Exchange permissions (admin)
|
||||||
MANAGE_AVAILABILITY: "manage_availability",
|
MANAGE_AVAILABILITY: "manage_availability",
|
||||||
VIEW_ALL_APPOINTMENTS: "view_all_appointments",
|
VIEW_ALL_EXCHANGES: "view_all_exchanges",
|
||||||
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
|
CANCEL_ANY_EXCHANGE: "cancel_any_exchange",
|
||||||
|
COMPLETE_EXCHANGE: "complete_exchange",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Use generated type from OpenAPI schema
|
// Use generated type from OpenAPI schema
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ function formatPrice(price: number): string {
|
||||||
|
|
||||||
export default function ExchangePage() {
|
export default function ExchangePage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredPermission: Permission.BOOK_APPOINTMENT,
|
requiredPermission: Permission.CREATE_EXCHANGE,
|
||||||
fallbackRedirect: "/",
|
fallbackRedirect: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,126 +316,6 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/booking/slots": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Get Available Slots
|
|
||||||
* @description Get available booking slots for a specific date.
|
|
||||||
*/
|
|
||||||
get: operations["get_available_slots_api_booking_slots_get"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/booking": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Create Booking
|
|
||||||
* @description Book an appointment slot.
|
|
||||||
*/
|
|
||||||
post: operations["create_booking_api_booking_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/appointments": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Get My Appointments
|
|
||||||
* @description Get the current user's appointments, sorted by date (upcoming first).
|
|
||||||
*/
|
|
||||||
get: operations["get_my_appointments_api_appointments_get"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/appointments/{appointment_id}/cancel": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Cancel My Appointment
|
|
||||||
* @description Cancel one of the current user's appointments.
|
|
||||||
*/
|
|
||||||
post: operations["cancel_my_appointment_api_appointments__appointment_id__cancel_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/appointments": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Get All Appointments
|
|
||||||
* @description Get all appointments (admin only), sorted by date descending with pagination.
|
|
||||||
*/
|
|
||||||
get: operations["get_all_appointments_api_admin_appointments_get"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/appointments/{appointment_id}/cancel": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Admin Cancel Appointment
|
|
||||||
* @description Cancel any appointment (admin only).
|
|
||||||
*/
|
|
||||||
post: operations["admin_cancel_appointment_api_admin_appointments__appointment_id__cancel_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/exchange/price": {
|
"/api/exchange/price": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -465,6 +345,30 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/exchange/slots": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Available Slots
|
||||||
|
* @description Get available booking slots for a specific date.
|
||||||
|
*
|
||||||
|
* Returns all slots that:
|
||||||
|
* - Fall within admin-defined availability windows
|
||||||
|
* - Are not already booked by another user
|
||||||
|
*/
|
||||||
|
get: operations["get_available_slots_api_exchange_slots_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/exchange": {
|
"/api/exchange": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -697,39 +601,6 @@ export interface components {
|
||||||
/** Email */
|
/** Email */
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* AppointmentResponse
|
|
||||||
* @description Response model for an appointment.
|
|
||||||
*/
|
|
||||||
AppointmentResponse: {
|
|
||||||
/** Id */
|
|
||||||
id: number;
|
|
||||||
/** User Id */
|
|
||||||
user_id: number;
|
|
||||||
/** User Email */
|
|
||||||
user_email: string;
|
|
||||||
/**
|
|
||||||
* Slot Start
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
slot_start: string;
|
|
||||||
/**
|
|
||||||
* Slot End
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
slot_end: string;
|
|
||||||
/** Note */
|
|
||||||
note: string | null;
|
|
||||||
/** Status */
|
|
||||||
status: string;
|
|
||||||
/**
|
|
||||||
* Created At
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
created_at: string;
|
|
||||||
/** Cancelled At */
|
|
||||||
cancelled_at: string | null;
|
|
||||||
};
|
|
||||||
/**
|
/**
|
||||||
* AvailabilityDay
|
* AvailabilityDay
|
||||||
* @description Availability for a single day.
|
* @description Availability for a single day.
|
||||||
|
|
@ -753,7 +624,7 @@ export interface components {
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* AvailableSlotsResponse
|
* AvailableSlotsResponse
|
||||||
* @description Response for available slots on a given date.
|
* @description Response containing available slots for a date.
|
||||||
*/
|
*/
|
||||||
AvailableSlotsResponse: {
|
AvailableSlotsResponse: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -766,7 +637,7 @@ export interface components {
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* BookableSlot
|
* BookableSlot
|
||||||
* @description A bookable 15-minute slot.
|
* @description A single bookable time slot.
|
||||||
*/
|
*/
|
||||||
BookableSlot: {
|
BookableSlot: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -780,19 +651,6 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
end_time: string;
|
end_time: string;
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* BookingRequest
|
|
||||||
* @description Request to book an appointment.
|
|
||||||
*/
|
|
||||||
BookingRequest: {
|
|
||||||
/**
|
|
||||||
* Slot Start
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
slot_start: string;
|
|
||||||
/** Note */
|
|
||||||
note?: string | null;
|
|
||||||
};
|
|
||||||
/**
|
/**
|
||||||
* ConstantsResponse
|
* ConstantsResponse
|
||||||
* @description Response model for shared constants.
|
* @description Response model for shared constants.
|
||||||
|
|
@ -981,19 +839,6 @@ export interface components {
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
InviteStatus: "ready" | "spent" | "revoked";
|
InviteStatus: "ready" | "spent" | "revoked";
|
||||||
/** PaginatedResponse[AppointmentResponse] */
|
|
||||||
PaginatedResponse_AppointmentResponse_: {
|
|
||||||
/** Records */
|
|
||||||
records: components["schemas"]["AppointmentResponse"][];
|
|
||||||
/** Total */
|
|
||||||
total: number;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/** Per Page */
|
|
||||||
per_page: number;
|
|
||||||
/** Total Pages */
|
|
||||||
total_pages: number;
|
|
||||||
};
|
|
||||||
/** PaginatedResponse[InviteResponse] */
|
/** PaginatedResponse[InviteResponse] */
|
||||||
PaginatedResponse_InviteResponse_: {
|
PaginatedResponse_InviteResponse_: {
|
||||||
/** Records */
|
/** Records */
|
||||||
|
|
@ -1012,7 +857,7 @@ export interface components {
|
||||||
* @description All available permissions in the system.
|
* @description All available permissions in the system.
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
Permission: "view_audit" | "fetch_price" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "book_appointment" | "view_own_appointments" | "cancel_own_appointment" | "manage_availability" | "view_all_appointments" | "cancel_any_appointment";
|
Permission: "view_audit" | "fetch_price" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "create_exchange" | "view_own_exchanges" | "cancel_own_exchange" | "manage_availability" | "view_all_exchanges" | "cancel_any_exchange" | "complete_exchange";
|
||||||
/**
|
/**
|
||||||
* PriceHistoryResponse
|
* PriceHistoryResponse
|
||||||
* @description Response model for a price history record.
|
* @description Response model for a price history record.
|
||||||
|
|
@ -1688,10 +1533,29 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
get_available_slots_api_booking_slots_get: {
|
get_exchange_price_api_exchange_price_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ExchangePriceResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_available_slots_api_exchange_slots_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
/** @description Date to get slots for */
|
|
||||||
date: string;
|
date: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
|
|
@ -1720,173 +1584,6 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
create_booking_api_booking_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["BookingRequest"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AppointmentResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
get_my_appointments_api_appointments_get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AppointmentResponse"][];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
cancel_my_appointment_api_appointments__appointment_id__cancel_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
appointment_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AppointmentResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
get_all_appointments_api_admin_appointments_get: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
page?: number;
|
|
||||||
per_page?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["PaginatedResponse_AppointmentResponse_"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
admin_cancel_appointment_api_admin_appointments__appointment_id__cancel_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
appointment_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AppointmentResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
get_exchange_price_api_exchange_price_get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ExchangePriceResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
create_exchange_api_exchange_post: {
|
create_exchange_api_exchange_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
|
||||||
id: 1,
|
id: 1,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
roles: ["regular"],
|
roles: ["regular"],
|
||||||
permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"],
|
permissions: [
|
||||||
|
"create_exchange",
|
||||||
|
"view_own_exchanges",
|
||||||
|
"cancel_own_exchange",
|
||||||
|
"manage_own_profile",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
let mockIsLoading = false;
|
let mockIsLoading = false;
|
||||||
const mockLogout = vi.fn();
|
const mockLogout = vi.fn();
|
||||||
|
|
@ -33,19 +38,18 @@ vi.mock("../auth-context", () => ({
|
||||||
hasPermission: mockHasPermission,
|
hasPermission: mockHasPermission,
|
||||||
}),
|
}),
|
||||||
Permission: {
|
Permission: {
|
||||||
VIEW_COUNTER: "view_counter",
|
|
||||||
INCREMENT_COUNTER: "increment_counter",
|
|
||||||
USE_SUM: "use_sum",
|
|
||||||
VIEW_AUDIT: "view_audit",
|
VIEW_AUDIT: "view_audit",
|
||||||
|
FETCH_PRICE: "fetch_price",
|
||||||
MANAGE_OWN_PROFILE: "manage_own_profile",
|
MANAGE_OWN_PROFILE: "manage_own_profile",
|
||||||
MANAGE_INVITES: "manage_invites",
|
MANAGE_INVITES: "manage_invites",
|
||||||
VIEW_OWN_INVITES: "view_own_invites",
|
VIEW_OWN_INVITES: "view_own_invites",
|
||||||
BOOK_APPOINTMENT: "book_appointment",
|
CREATE_EXCHANGE: "create_exchange",
|
||||||
VIEW_OWN_APPOINTMENTS: "view_own_appointments",
|
VIEW_OWN_EXCHANGES: "view_own_exchanges",
|
||||||
CANCEL_OWN_APPOINTMENT: "cancel_own_appointment",
|
CANCEL_OWN_EXCHANGE: "cancel_own_exchange",
|
||||||
MANAGE_AVAILABILITY: "manage_availability",
|
MANAGE_AVAILABILITY: "manage_availability",
|
||||||
VIEW_ALL_APPOINTMENTS: "view_all_appointments",
|
VIEW_ALL_EXCHANGES: "view_all_exchanges",
|
||||||
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
|
CANCEL_ANY_EXCHANGE: "cancel_any_exchange",
|
||||||
|
COMPLETE_EXCHANGE: "complete_exchange",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -64,7 +68,12 @@ beforeEach(() => {
|
||||||
id: 1,
|
id: 1,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
roles: ["regular"],
|
roles: ["regular"],
|
||||||
permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"],
|
permissions: [
|
||||||
|
"create_exchange",
|
||||||
|
"view_own_exchanges",
|
||||||
|
"cancel_own_exchange",
|
||||||
|
"manage_own_profile",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
mockIsLoading = false;
|
mockIsLoading = false;
|
||||||
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ function getTradeStatusDisplay(status: string): {
|
||||||
|
|
||||||
export default function TradesPage() {
|
export default function TradesPage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
|
requiredPermission: Permission.VIEW_OWN_EXCHANGES,
|
||||||
fallbackRedirect: "/",
|
fallbackRedirect: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,12 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
// Click tomorrow's date using the weekday name
|
// Click tomorrow's date using the weekday name
|
||||||
|
// Wait for the button to be enabled (availability loading must complete)
|
||||||
const dateButton = page
|
const dateButton = page
|
||||||
.locator("button")
|
.locator("button")
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
.filter({ hasText: new RegExp(`^${weekday}`) })
|
||||||
.first();
|
.first();
|
||||||
|
await expect(dateButton).toBeEnabled({ timeout: 15000 });
|
||||||
await dateButton.click();
|
await dateButton.click();
|
||||||
|
|
||||||
// Wait for "Available Slots" section to appear
|
// Wait for "Available Slots" section to appear
|
||||||
|
|
@ -143,11 +145,12 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
// Click tomorrow's date
|
// Click tomorrow's date - wait for button to be enabled first
|
||||||
const dateButton = page
|
const dateButton = page
|
||||||
.locator("button")
|
.locator("button")
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
.filter({ hasText: new RegExp(`^${weekday}`) })
|
||||||
.first();
|
.first();
|
||||||
|
await expect(dateButton).toBeEnabled({ timeout: 15000 });
|
||||||
await dateButton.click();
|
await dateButton.click();
|
||||||
|
|
||||||
// Wait for any slot to appear
|
// Wait for any slot to appear
|
||||||
|
|
@ -171,11 +174,12 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
// Click tomorrow's date
|
// Click tomorrow's date - wait for button to be enabled first
|
||||||
const dateButton = page
|
const dateButton = page
|
||||||
.locator("button")
|
.locator("button")
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
.filter({ hasText: new RegExp(`^${weekday}`) })
|
||||||
.first();
|
.first();
|
||||||
|
await expect(dateButton).toBeEnabled({ timeout: 15000 });
|
||||||
await dateButton.click();
|
await dateButton.click();
|
||||||
|
|
||||||
// Wait for slots to load
|
// Wait for slots to load
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ test.describe("Regular User Access", () => {
|
||||||
// Should stay on trades page
|
// Should stay on trades page
|
||||||
await expect(page).toHaveURL("/trades");
|
await expect(page).toHaveURL("/trades");
|
||||||
|
|
||||||
// Should see trades UI
|
// Should see trades UI heading
|
||||||
await expect(page.getByText("My Trades")).toBeVisible();
|
await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigation shows exchange and trades", async ({ page }) => {
|
test("navigation shows exchange and trades", async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ test.describe("Price History - E2E", () => {
|
||||||
// Try to navigate directly to the admin page
|
// Try to navigate directly to the admin page
|
||||||
await page.goto("/admin/price-history");
|
await page.goto("/admin/price-history");
|
||||||
|
|
||||||
// Should be redirected away (to "/" since fallbackRedirect is "/")
|
// Should be redirected away (regular users go to /exchange)
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/exchange");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("price history shows in navigation for admin", async ({ page }) => {
|
test("price history shows in navigation for admin", async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue