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:
counterweight 2025-12-22 21:42:42 +01:00
parent 65ba4dc42a
commit 1008eea2d9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
11 changed files with 184 additions and 379 deletions

View file

@ -2,7 +2,7 @@
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 sqlalchemy import and_, desc, select
from sqlalchemy.exc import IntegrityError
@ -78,6 +78,20 @@ class ExchangePriceResponse(BaseModel):
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
# =============================================================================
@ -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
# =============================================================================

View file

@ -22,7 +22,7 @@ import {
modalStyles,
} from "../../styles/shared";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking;
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.exchange;
type _AvailabilityDay = components["schemas"]["AvailabilityDay"];
type AvailabilityResponse = components["schemas"]["AvailabilityResponse"];

View file

@ -88,7 +88,7 @@ type Tab = "upcoming" | "past";
export default function AdminTradesPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_ALL_APPOINTMENTS,
requiredPermission: Permission.VIEW_ALL_EXCHANGES,
fallbackRedirect: "/",
});

View file

@ -17,14 +17,15 @@ export const Permission: Record<string, PermissionType> = {
MANAGE_OWN_PROFILE: "manage_own_profile",
MANAGE_INVITES: "manage_invites",
VIEW_OWN_INVITES: "view_own_invites",
// Booking permissions (regular users)
BOOK_APPOINTMENT: "book_appointment",
VIEW_OWN_APPOINTMENTS: "view_own_appointments",
CANCEL_OWN_APPOINTMENT: "cancel_own_appointment",
// Availability/Appointments permissions (admin)
// Exchange permissions (regular users)
CREATE_EXCHANGE: "create_exchange",
VIEW_OWN_EXCHANGES: "view_own_exchanges",
CANCEL_OWN_EXCHANGE: "cancel_own_exchange",
// Availability/Exchange permissions (admin)
MANAGE_AVAILABILITY: "manage_availability",
VIEW_ALL_APPOINTMENTS: "view_all_appointments",
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
VIEW_ALL_EXCHANGES: "view_all_exchanges",
CANCEL_ANY_EXCHANGE: "cancel_any_exchange",
COMPLETE_EXCHANGE: "complete_exchange",
} as const;
// Use generated type from OpenAPI schema

View file

@ -51,7 +51,7 @@ function formatPrice(price: number): string {
export default function ExchangePage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.BOOK_APPOINTMENT,
requiredPermission: Permission.CREATE_EXCHANGE,
fallbackRedirect: "/",
});

View file

@ -316,126 +316,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -465,6 +345,30 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -697,39 +601,6 @@ export interface components {
/** Email */
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
* @description Availability for a single day.
@ -753,7 +624,7 @@ export interface components {
};
/**
* AvailableSlotsResponse
* @description Response for available slots on a given date.
* @description Response containing available slots for a date.
*/
AvailableSlotsResponse: {
/**
@ -766,7 +637,7 @@ export interface components {
};
/**
* BookableSlot
* @description A bookable 15-minute slot.
* @description A single bookable time slot.
*/
BookableSlot: {
/**
@ -780,19 +651,6 @@ export interface components {
*/
end_time: string;
};
/**
* BookingRequest
* @description Request to book an appointment.
*/
BookingRequest: {
/**
* Slot Start
* Format: date-time
*/
slot_start: string;
/** Note */
note?: string | null;
};
/**
* ConstantsResponse
* @description Response model for shared constants.
@ -981,19 +839,6 @@ export interface components {
* @enum {string}
*/
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_: {
/** Records */
@ -1012,7 +857,7 @@ export interface components {
* @description All available permissions in the system.
* @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
* @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: {
query: {
/** @description Date to get slots for */
date: string;
};
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: {
parameters: {
query?: never;

View file

@ -15,7 +15,12 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
id: 1,
email: "test@example.com",
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;
const mockLogout = vi.fn();
@ -33,19 +38,18 @@ vi.mock("../auth-context", () => ({
hasPermission: mockHasPermission,
}),
Permission: {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
FETCH_PRICE: "fetch_price",
MANAGE_OWN_PROFILE: "manage_own_profile",
MANAGE_INVITES: "manage_invites",
VIEW_OWN_INVITES: "view_own_invites",
BOOK_APPOINTMENT: "book_appointment",
VIEW_OWN_APPOINTMENTS: "view_own_appointments",
CANCEL_OWN_APPOINTMENT: "cancel_own_appointment",
CREATE_EXCHANGE: "create_exchange",
VIEW_OWN_EXCHANGES: "view_own_exchanges",
CANCEL_OWN_EXCHANGE: "cancel_own_exchange",
MANAGE_AVAILABILITY: "manage_availability",
VIEW_ALL_APPOINTMENTS: "view_all_appointments",
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
VIEW_ALL_EXCHANGES: "view_all_exchanges",
CANCEL_ANY_EXCHANGE: "cancel_any_exchange",
COMPLETE_EXCHANGE: "complete_exchange",
},
}));
@ -64,7 +68,12 @@ beforeEach(() => {
id: 1,
email: "test@example.com",
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;
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);

View file

@ -86,7 +86,7 @@ function getTradeStatusDisplay(status: string): {
export default function TradesPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
requiredPermission: Permission.VIEW_OWN_EXCHANGES,
fallbackRedirect: "/",
});

View file

@ -118,10 +118,12 @@ test.describe("Exchange Page - With Availability", () => {
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date using the weekday name
// Wait for the button to be enabled (availability loading must complete)
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await expect(dateButton).toBeEnabled({ timeout: 15000 });
await dateButton.click();
// Wait for "Available Slots" section to appear
@ -143,11 +145,12 @@ test.describe("Exchange Page - With Availability", () => {
tomorrow.setDate(tomorrow.getDate() + 1);
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
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await expect(dateButton).toBeEnabled({ timeout: 15000 });
await dateButton.click();
// Wait for any slot to appear
@ -171,11 +174,12 @@ test.describe("Exchange Page - With Availability", () => {
tomorrow.setDate(tomorrow.getDate() + 1);
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
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await expect(dateButton).toBeEnabled({ timeout: 15000 });
await dateButton.click();
// Wait for slots to load

View file

@ -87,8 +87,8 @@ test.describe("Regular User Access", () => {
// Should stay on trades page
await expect(page).toHaveURL("/trades");
// Should see trades UI
await expect(page.getByText("My Trades")).toBeVisible();
// Should see trades UI heading
await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible();
});
test("navigation shows exchange and trades", async ({ page }) => {

View file

@ -53,8 +53,8 @@ test.describe("Price History - E2E", () => {
// Try to navigate directly to the admin page
await page.goto("/admin/price-history");
// Should be redirected away (to "/" since fallbackRedirect is "/")
await expect(page).toHaveURL("/");
// Should be redirected away (regular users go to /exchange)
await expect(page).toHaveURL("/exchange");
});
test("price history shows in navigation for admin", async ({ page }) => {