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 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
# ============================================================================= # =============================================================================

View file

@ -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"];

View file

@ -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: "/",
}); });

View file

@ -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

View file

@ -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: "/",
}); });

View file

@ -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;

View file

@ -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);

View file

@ -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: "/",
}); });

View file

@ -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

View file

@ -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 }) => {

View file

@ -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 }) => {