Phase 7: Final cleanup - Remove deprecated booking/appointment code

Deleted deprecated files:
- backend/routes/booking.py
- frontend/app/admin/appointments/, booking/, appointments/, sum/, audit/
- frontend/app/utils/appointment.ts
- frontend/e2e/booking.spec.ts, appointments.spec.ts

Updated references:
- exchange/page.tsx: Use /api/exchange/slots instead of /api/booking/slots
- useRequireAuth.ts: Redirect to /admin/trades and /exchange
- profile.tsx, invites.tsx: Update fallback redirect
- E2E tests: Update all /audit references to /admin/trades
- profile.test.tsx: Update admin redirect test
This commit is contained in:
counterweight 2025-12-22 20:18:33 +01:00
parent 9e8d0af435
commit bbd9fae763
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
16 changed files with 29 additions and 2103 deletions

View file

@ -1,319 +0,0 @@
"use client";
import React from "react";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import { formatDateTime } from "../../utils/date";
import { getStatusDisplay } from "../../utils/appointment";
import { sharedStyles } from "../../styles/shared";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
type PaginatedAppointments = components["schemas"]["PaginatedResponse_AppointmentResponse_"];
const pageStyles: Record<string, React.CSSProperties> = {
main: {
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
display: "flex",
flexDirection: "column",
},
loader: {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
},
content: {
flex: 1,
padding: "2rem",
maxWidth: "900px",
margin: "0 auto",
width: "100%",
},
pageTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.75rem",
fontWeight: 600,
color: "#fff",
marginBottom: "0.5rem",
},
pageSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.9rem",
marginBottom: "1.5rem",
},
filterRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "1.5rem",
},
filterLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.6)",
fontSize: "0.875rem",
},
filterSelect: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.5rem 1rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "6px",
color: "#fff",
fontSize: "0.875rem",
},
appointmentList: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
appointmentCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.25rem",
transition: "all 0.2s",
},
appointmentCardPast: {
opacity: 0.6,
},
appointmentHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "1rem",
},
appointmentTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "0.25rem",
},
appointmentUser: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
marginBottom: "0.25rem",
},
appointmentNote: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.4)",
fontStyle: "italic",
marginBottom: "0.5rem",
},
statusBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.75rem",
borderRadius: "9999px",
display: "inline-block",
},
buttonGroup: {
display: "flex",
gap: "0.5rem",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "6px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
confirmButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(239, 68, 68, 0.2)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
color: "#f87171",
cursor: "pointer",
transition: "all 0.2s",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "center",
padding: "3rem",
},
};
const styles = { ...sharedStyles, ...pageStyles };
export default function AdminAppointmentsPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_ALL_APPOINTMENTS,
fallbackRedirect: "/",
});
const [appointments, setAppointments] = useState<AppointmentResponse[]>([]);
const [isLoadingAppointments, setIsLoadingAppointments] = useState(true);
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("all");
const fetchAppointments = useCallback(async () => {
try {
// Fetch with large per_page to get all appointments for now
const data = await api.get<PaginatedAppointments>("/api/admin/appointments?per_page=100");
setAppointments(data.records);
} catch (err) {
console.error("Failed to fetch appointments:", err);
setError("Failed to load appointments");
} finally {
setIsLoadingAppointments(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchAppointments();
}
}, [user, isAuthorized, fetchAppointments]);
const handleCancel = async (appointmentId: number) => {
setCancellingId(appointmentId);
setError(null);
try {
await api.post<AppointmentResponse>(`/api/admin/appointments/${appointmentId}/cancel`, {});
await fetchAppointments();
setConfirmCancelId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel appointment");
} finally {
setCancellingId(null);
}
};
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!isAuthorized) {
return null;
}
const filteredAppointments = appointments.filter((apt) => {
if (statusFilter === "all") return true;
return apt.status === statusFilter;
});
const bookedCount = appointments.filter((a) => a.status === "booked").length;
return (
<main style={styles.main}>
<Header currentPage="admin-appointments" />
<div style={styles.content}>
<h1 style={styles.pageTitle}>All Appointments</h1>
<p style={styles.pageSubtitle}>View and manage all user appointments</p>
{error && <div style={styles.errorBanner}>{error}</div>}
{/* Status Filter */}
<div style={styles.filterRow}>
<span style={styles.filterLabel}>Filter:</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="all">All ({appointments.length})</option>
<option value="booked">Booked ({bookedCount})</option>
<option value="cancelled_by_user">Cancelled by User</option>
<option value="cancelled_by_admin">Cancelled by Admin</option>
</select>
</div>
{isLoadingAppointments ? (
<div style={styles.emptyState}>Loading appointments...</div>
) : appointments.length === 0 ? (
<div style={styles.emptyState}>No appointments yet.</div>
) : filteredAppointments.length === 0 ? (
<div style={styles.emptyState}>No appointments match the filter.</div>
) : (
<div style={styles.appointmentList}>
{filteredAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
const isPast = new Date(apt.slot_start) <= new Date();
return (
<div
key={apt.id}
style={{
...styles.appointmentCard,
...(isPast ? styles.appointmentCardPast : {}),
}}
>
<div style={styles.appointmentHeader}>
<div>
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
<div style={styles.appointmentUser}>{apt.user_email}</div>
{apt.note && <div style={styles.appointmentNote}>&quot;{apt.note}&quot;</div>}
<span
style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div style={styles.buttonGroup}>
{confirmCancelId === apt.id ? (
<>
<button
onClick={() => handleCancel(apt.id)}
disabled={cancellingId === apt.id}
style={styles.confirmButton}
>
{cancellingId === apt.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={styles.cancelButton}
>
No
</button>
</>
) : (
<button
onClick={() => setConfirmCancelId(apt.id)}
style={styles.cancelButton}
>
Cancel
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</main>
);
}

View file

@ -1,285 +0,0 @@
"use client";
import React from "react";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
import { formatDateTime } from "../utils/date";
import { getStatusDisplay } from "../utils/appointment";
import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
} from "../styles/shared";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
export default function AppointmentsPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
fallbackRedirect: "/",
});
const [appointments, setAppointments] = useState<AppointmentResponse[]>([]);
const [isLoadingAppointments, setIsLoadingAppointments] = useState(true);
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchAppointments = useCallback(async () => {
try {
const data = await api.get<AppointmentResponse[]>("/api/appointments");
setAppointments(data);
} catch (err) {
console.error("Failed to fetch appointments:", err);
setError("Failed to load appointments");
} finally {
setIsLoadingAppointments(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchAppointments();
}
}, [user, isAuthorized, fetchAppointments]);
const handleCancel = async (appointmentId: number) => {
setCancellingId(appointmentId);
setError(null);
try {
await api.post<AppointmentResponse>(`/api/appointments/${appointmentId}/cancel`, {});
await fetchAppointments();
setConfirmCancelId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel appointment");
} finally {
setCancellingId(null);
}
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!isAuthorized) {
return null;
}
const upcomingAppointments = appointments.filter(
(apt) => apt.status === "booked" && new Date(apt.slot_start) > new Date()
);
const pastOrCancelledAppointments = appointments.filter(
(apt) => apt.status !== "booked" || new Date(apt.slot_start) <= new Date()
);
return (
<main style={layoutStyles.main}>
<Header currentPage="appointments" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>My Appointments</h1>
<p style={typographyStyles.pageSubtitle}>View and manage your booked appointments</p>
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{isLoadingAppointments ? (
<div style={styles.emptyState}>Loading appointments...</div>
) : appointments.length === 0 ? (
<div style={styles.emptyState}>
<p>You don&apos;t have any appointments yet.</p>
<a href="/booking" style={styles.emptyStateLink}>
Book an appointment
</a>
</div>
) : (
<>
{/* Upcoming Appointments */}
{upcomingAppointments.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Upcoming ({upcomingAppointments.length})</h2>
<div style={styles.appointmentList}>
{upcomingAppointments.map((apt) => {
const status = getStatusDisplay(apt.status, true);
return (
<div key={apt.id} style={styles.appointmentCard}>
<div style={styles.appointmentHeader}>
<div>
<div style={styles.appointmentTime}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div style={styles.buttonGroup}>
{confirmCancelId === apt.id ? (
<>
<button
onClick={() => handleCancel(apt.id)}
disabled={cancellingId === apt.id}
style={styles.confirmButton}
>
{cancellingId === apt.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={buttonStyles.secondaryButton}
>
No
</button>
</>
) : (
<button
onClick={() => setConfirmCancelId(apt.id)}
style={buttonStyles.secondaryButton}
>
Cancel
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Past/Cancelled Appointments */}
{pastOrCancelledAppointments.length > 0 && (
<div style={styles.section}>
<h2 style={typographyStyles.sectionTitleMuted}>
Past & Cancelled ({pastOrCancelledAppointments.length})
</h2>
<div style={styles.appointmentList}>
{pastOrCancelledAppointments.map((apt) => {
const status = getStatusDisplay(apt.status, true);
return (
<div
key={apt.id}
style={{ ...styles.appointmentCard, ...styles.appointmentCardPast }}
>
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
maxWidth: "800px",
margin: "0 auto",
width: "100%",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
appointmentList: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
appointmentCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.25rem",
transition: "all 0.2s",
},
appointmentCardPast: {
opacity: 0.6,
background: "rgba(255, 255, 255, 0.01)",
},
appointmentHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "1rem",
},
appointmentTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "0.25rem",
},
appointmentNote: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
marginBottom: "0.5rem",
},
buttonGroup: {
display: "flex",
gap: "0.5rem",
},
confirmButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(239, 68, 68, 0.2)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
color: "#f87171",
cursor: "pointer",
transition: "all 0.2s",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "center",
padding: "3rem",
},
emptyStateLink: {
color: "#a78bfa",
textDecoration: "none",
},
};

View file

@ -1,428 +0,0 @@
"use client";
import React from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
import constants from "../../../shared/constants.json";
import { formatDate, formatTime, getDateRange } from "../utils/date";
import {
layoutStyles,
typographyStyles,
bannerStyles,
formStyles,
buttonStyles,
} from "../styles/shared";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
type BookableSlot = components["schemas"]["BookableSlot"];
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
export default function BookingPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.BOOK_APPOINTMENT,
fallbackRedirect: "/",
});
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<BookableSlot | null>(null);
const [note, setNote] = useState("");
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
const [isBooking, setIsBooking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
// Memoize dates to prevent infinite re-renders
const dates = useMemo(
() => getDateRange(minAdvanceDays, maxAdvanceDays),
[minAdvanceDays, maxAdvanceDays]
);
const fetchSlots = useCallback(async (date: Date) => {
setIsLoadingSlots(true);
setError(null);
setAvailableSlots([]);
setSelectedSlot(null);
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
setAvailableSlots(data.slots);
} catch (err) {
console.error("Failed to fetch slots:", err);
setError("Failed to load available slots");
} finally {
setIsLoadingSlots(false);
}
}, []);
// Fetch availability for all dates on mount
useEffect(() => {
if (!user || !isAuthorized) return;
const fetchAllAvailability = async () => {
setIsLoadingAvailability(true);
const availabilitySet = new Set<string>();
// Fetch availability for all dates in parallel
const promises = dates.map(async (date) => {
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
if (data.slots.length > 0) {
availabilitySet.add(dateStr);
}
} catch (err) {
// Silently fail for individual dates - they'll just be marked as unavailable
console.error(`Failed to fetch availability for ${formatDate(date)}:`, err);
}
});
await Promise.all(promises);
setDatesWithAvailability(availabilitySet);
setIsLoadingAvailability(false);
};
fetchAllAvailability();
}, [user, isAuthorized]); // Removed dates from dependencies - dates is memoized and stable
useEffect(() => {
if (selectedDate && user && isAuthorized) {
fetchSlots(selectedDate);
}
}, [selectedDate, user, isAuthorized, fetchSlots]);
const handleDateSelect = (date: Date) => {
const dateStr = formatDate(date);
// Only allow selection if date has availability
if (datesWithAvailability.has(dateStr)) {
setSelectedDate(date);
setSuccessMessage(null);
}
};
const handleSlotSelect = (slot: BookableSlot) => {
setSelectedSlot(slot);
setNote("");
setError(null);
};
const handleBook = async () => {
if (!selectedSlot) return;
setIsBooking(true);
setError(null);
try {
const appointment = await api.post<AppointmentResponse>("/api/booking", {
slot_start: selectedSlot.start_time,
note: note || null,
});
setSuccessMessage(
`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`
);
setSelectedSlot(null);
setNote("");
// Refresh slots to show the booked one is gone
if (selectedDate) {
await fetchSlots(selectedDate);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to book appointment");
} finally {
setIsBooking(false);
}
};
const cancelSlotSelection = () => {
setSelectedSlot(null);
setNote("");
setError(null);
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!isAuthorized) {
return null;
}
return (
<main style={layoutStyles.main}>
<Header currentPage="booking" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>Book an Appointment</h1>
<p style={typographyStyles.pageSubtitle}>
Select a date to see available {slotDurationMinutes}-minute slots
</p>
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{/* Date Selection */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const dateStr = formatDate(date);
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
return (
<button
key={dateStr}
onClick={() => handleDateSelect(date)}
disabled={isDisabled}
style={{
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}),
}}
>
<div style={styles.dateWeekday}>
{date.toLocaleDateString("en-US", { weekday: "short" })}
</div>
<div style={styles.dateDay}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
</button>
);
})}
</div>
</div>
{/* Available Slots */}
{selectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</h2>
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
<div style={styles.emptyState}>No available slots for this date</div>
) : (
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
<button
key={slot.start_time}
onClick={() => handleSlotSelect(slot)}
style={{
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
}}
>
{formatTime(slot.start_time)}
</button>
);
})}
</div>
)}
</div>
)}
{/* Booking Form */}
{selectedSlot && (
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
<p style={styles.confirmTime}>
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} -{" "}
{formatTime(selectedSlot.end_time)}
</p>
<div>
<label style={formStyles.label}>Note (optional, max {noteMaxLength} chars)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
placeholder="Add a note about your appointment..."
style={formStyles.textarea}
/>
<div
style={{
...formStyles.charCount,
...(note.length >= noteMaxLength ? formStyles.charCountWarning : {}),
}}
>
{note.length}/{noteMaxLength}
</div>
</div>
<div style={styles.buttonRow}>
<button
onClick={handleBook}
disabled={isBooking}
style={{
...styles.bookButton,
...(isBooking ? buttonStyles.buttonDisabled : {}),
}}
>
{isBooking ? "Booking..." : "Book Appointment"}
</button>
<button
onClick={cancelSlotSelection}
disabled={isBooking}
style={styles.cancelButton}
>
Cancel
</button>
</div>
</div>
)}
</div>
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
maxWidth: "900px",
margin: "0 auto",
width: "100%",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
dateGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
dateButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "10px",
cursor: "pointer",
minWidth: "90px",
textAlign: "center" as const,
transition: "all 0.2s",
},
dateButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
},
dateButtonDisabled: {
opacity: 0.4,
cursor: "not-allowed",
background: "rgba(255, 255, 255, 0.01)",
border: "1px solid rgba(255, 255, 255, 0.04)",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
fontSize: "0.875rem",
marginBottom: "0.25rem",
},
dateDay: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
slotGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
slotButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.6rem 1.25rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
fontSize: "0.9rem",
transition: "all 0.2s",
},
slotButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
padding: "1rem 0",
},
confirmCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
maxWidth: "400px",
},
confirmTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
confirmTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
marginBottom: "1rem",
},
buttonRow: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
bookButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
flex: 1,
padding: "0.75rem",
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.2s",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1.25rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
};

View file

@ -152,7 +152,7 @@ export default function ExchangePage() {
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
setAvailableSlots(data.slots);
} catch (err) {
console.error("Failed to fetch slots:", err);
@ -173,7 +173,7 @@ export default function ExchangePage() {
const promises = dates.map(async (date) => {
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
if (data.slots.length > 0) {
availabilitySet.add(dateStr);
}

View file

@ -48,10 +48,10 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
// Redirect to the most appropriate page based on permissions
const redirect =
fallbackRedirect ??
(hasPermission(Permission.VIEW_AUDIT)
? "/audit"
: hasPermission(Permission.VIEW_COUNTER)
? "/"
(hasPermission(Permission.VIEW_ALL_EXCHANGES)
? "/admin/trades"
: hasPermission(Permission.CREATE_EXCHANGE)
? "/exchange"
: "/login");
router.push(redirect);
}

View file

@ -21,7 +21,7 @@ type Invite = components["schemas"]["UserInviteResponse"];
export default function InvitesPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_INVITES,
fallbackRedirect: "/audit",
fallbackRedirect: "/admin/trades",
});
const [invites, setInvites] = useState<Invite[]>([]);
const [isLoadingInvites, setIsLoadingInvites] = useState(true);

View file

@ -242,18 +242,18 @@ describe("ProfilePage - Access Control", () => {
});
});
test("redirects admin to audit page", async () => {
test("redirects admin to admin trades page", async () => {
mockUser = {
id: 1,
email: "admin@example.com",
roles: ["admin"],
permissions: ["view_audit"],
permissions: ["view_all_exchanges"],
};
render(<ProfilePage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/audit");
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
});
});

View file

@ -40,7 +40,7 @@ function toFormData(data: ProfileData): FormData {
export default function ProfilePage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.MANAGE_OWN_PROFILE,
fallbackRedirect: "/audit",
fallbackRedirect: "/admin/trades",
});
const [originalData, setOriginalData] = useState<FormData | null>(null);
const [formData, setFormData] = useState<FormData>({

View file

@ -1,36 +0,0 @@
/**
* Appointment-related utilities.
*/
export interface StatusDisplay {
text: string;
bgColor: string;
textColor: string;
}
/**
* Get display information for an appointment status.
*
* @param status - The appointment status string
* @param isOwnView - If true, uses "Cancelled by you" instead of "Cancelled by user"
*/
export function getStatusDisplay(status: string, isOwnView: boolean = false): StatusDisplay {
switch (status) {
case "booked":
return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" };
case "cancelled_by_user":
return {
text: isOwnView ? "Cancelled by you" : "Cancelled by user",
bgColor: "rgba(239, 68, 68, 0.2)",
textColor: "#f87171",
};
case "cancelled_by_admin":
return {
text: "Cancelled by admin",
bgColor: "rgba(239, 68, 68, 0.2)",
textColor: "#f87171",
};
default:
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
}
}

View file

@ -12,7 +12,7 @@ async function loginAsAdmin(page: Page) {
await page.fill('input[type="email"]', ADMIN_EMAIL);
await page.fill('input[type="password"]', ADMIN_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/audit");
await expect(page).toHaveURL("/admin/trades");
}
test.describe("Admin Invites Page", () => {

View file

@ -1,230 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { getTomorrowDateStr } from "./helpers/date";
import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
/**
* Appointments Page E2E Tests
*
* Tests for viewing and cancelling user appointments.
*/
// Set up availability and create a booking
async function createTestBooking(page: Page) {
const dateStr = getTomorrowDateStr();
// First login as admin to set availability
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const adminCookies = await page.context().cookies();
const adminAuthCookie = adminCookies.find((c) => c.name === "auth_token");
if (!adminAuthCookie) throw new Error("No admin auth cookie");
await page.request.put(`${API_URL}/api/admin/availability`, {
headers: {
Cookie: `auth_token=${adminAuthCookie.value}`,
"Content-Type": "application/json",
},
data: {
date: dateStr,
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
},
});
// Login as regular user
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const userCookies = await page.context().cookies();
const userAuthCookie = userCookies.find((c) => c.name === "auth_token");
if (!userAuthCookie) throw new Error("No user auth cookie");
// Create booking - use a random minute to avoid conflicts with parallel tests
const randomMinute = Math.floor(Math.random() * 11) * 15; // 0, 15, 30, 45 etc up to 165 min
const hour = 9 + Math.floor(randomMinute / 60);
const minute = randomMinute % 60;
const timeStr = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
const response = await page.request.post(`${API_URL}/api/booking`, {
headers: {
Cookie: `auth_token=${userAuthCookie.value}`,
"Content-Type": "application/json",
},
data: {
slot_start: `${dateStr}T${timeStr}Z`,
note: "Test appointment",
},
});
return response.json();
}
test.describe("Appointments Page - Regular User Access", () => {
test.beforeEach(async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
});
test("regular user can access appointments page", async ({ page }) => {
await page.goto("/appointments");
await expect(page).toHaveURL("/appointments");
await expect(page.getByRole("heading", { name: "My Appointments" })).toBeVisible();
});
test("regular user sees Appointments link in navigation", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Appointments" })).toBeVisible();
});
test("shows empty state when no appointments", async ({ page }) => {
await page.goto("/appointments");
await expect(page.getByText("don't have any appointments")).toBeVisible();
await expect(page.getByRole("link", { name: "Book an appointment" })).toBeVisible();
});
});
test.describe("Appointments Page - With Bookings", () => {
test("shows user's appointments", async ({ page }) => {
// Create a booking first
await createTestBooking(page);
// Go to appointments page
await page.goto("/appointments");
// Should see the appointment
await expect(page.getByText("Test appointment")).toBeVisible();
await expect(page.getByText("Booked", { exact: true })).toBeVisible();
});
test("can cancel an appointment", async ({ page }) => {
// Create a booking
await createTestBooking(page);
// Go to appointments page
await page.goto("/appointments");
// Click cancel button
await page.getByRole("button", { name: "Cancel" }).first().click();
// Confirm cancellation
await page.getByRole("button", { name: "Confirm" }).click();
// Should show cancelled status
await expect(page.getByText("Cancelled by you")).toBeVisible();
});
test("can abort cancellation", async ({ page }) => {
// Create a booking
await createTestBooking(page);
// Go to appointments page
await page.goto("/appointments");
// Wait for appointments to load
await expect(page.getByRole("heading", { name: /Upcoming/ })).toBeVisible({ timeout: 10000 });
// Click cancel button
await page.getByRole("button", { name: "Cancel" }).first().click();
// Click No to abort
await page.getByRole("button", { name: "No" }).click();
// Should still show as booked (use first() since there may be multiple bookings)
await expect(page.getByText("Booked", { exact: true }).first()).toBeVisible();
});
});
test.describe("Appointments Page - Access Control", () => {
test("admin cannot access appointments page", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/appointments");
// Should be redirected
await expect(page).not.toHaveURL("/appointments");
});
test("admin does not see Appointments link", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/audit");
await expect(page.getByRole("link", { name: "Appointments" })).not.toBeVisible();
});
test("unauthenticated user redirected to login", async ({ page }) => {
await clearAuth(page);
await page.goto("/appointments");
await expect(page).toHaveURL("/login");
});
});
test.describe("Appointments API", () => {
test("regular user can view appointments via API", async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const response = await page.request.get(`${API_URL}/api/appointments`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(200);
expect(Array.isArray(await response.json())).toBe(true);
}
});
test("regular user can cancel appointment via API", async ({ page }) => {
// Create a booking
const booking = await createTestBooking(page);
const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie && booking && booking.id) {
const response = await page.request.post(`${API_URL}/api/appointments/${booking.id}/cancel`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe("cancelled_by_user");
}
});
test("admin cannot view user appointments via API", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const response = await page.request.get(`${API_URL}/api/appointments`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
});

View file

@ -30,7 +30,7 @@ test.describe("Availability Page - Admin Access", () => {
});
test("admin sees Availability link in nav", async ({ page }) => {
await page.goto("/audit");
await page.goto("/admin/trades");
const availabilityLink = page.locator('a[href="/admin/availability"]');
await expect(availabilityLink).toBeVisible();

View file

@ -1,393 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { getTomorrowDateStr } from "./helpers/date";
import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
/**
* Booking Page E2E Tests
*
* Tests for the user booking page.
*/
// Set up availability for a date using the API with retry logic
async function setAvailability(page: Page, dateStr: string, maxRetries = 3) {
const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token");
if (!authCookie) {
throw new Error("No auth cookie found when trying to set availability");
}
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
// Wait before retry
await page.waitForTimeout(500);
}
const response = await page.request.put(`${API_URL}/api/admin/availability`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
date: dateStr,
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
},
});
if (response.ok()) {
return; // Success
}
const body = await response.text();
lastError = new Error(`Failed to set availability: ${response.status()} - ${body}`);
// Only retry on 500 errors
if (response.status() !== 500) {
throw lastError;
}
}
throw lastError;
}
test.describe("Booking Page - Regular User Access", () => {
test.beforeEach(async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
});
test("regular user can access booking page", async ({ page }) => {
await page.goto("/booking");
await expect(page).toHaveURL("/booking");
await expect(page.getByRole("heading", { name: "Book an Appointment" })).toBeVisible();
});
test("regular user sees Book link in navigation", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Book" })).toBeVisible();
});
test("booking page shows date selection", async ({ page }) => {
await page.goto("/booking");
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
// Should see multiple date buttons
const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
await expect(dateButtons.first()).toBeVisible();
});
test("selecting date shows slots section", async ({ page }) => {
// First set up availability for tomorrow so we have an enabled date
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await setAvailability(page, getTomorrowDateStr());
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await page.goto("/booking");
// Wait for availability check to complete
await page.waitForTimeout(2000);
// Find an enabled date button (one with availability)
const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
let enabledButton = null;
const buttonCount = await dateButtons.count();
for (let i = 0; i < buttonCount; i++) {
const button = dateButtons.nth(i);
const isDisabled = await button.isDisabled().catch(() => true);
if (!isDisabled) {
enabledButton = button;
break;
}
}
// Should have at least one enabled date (tomorrow)
expect(enabledButton).not.toBeNull();
await enabledButton!.click();
// Should show Available Slots section (use heading to be specific)
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
});
test("shows no slots or message when no availability", async ({ page }) => {
await page.goto("/booking");
// Wait for date buttons to load and availability check to complete
await page.waitForTimeout(2000);
// Find an enabled date button (one that has availability or is still loading)
// If all dates are disabled, we can't test clicking, so verify disabled state
const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
const enabledButtons = dateButtons.filter({ hasNotText: /disabled/ });
const enabledCount = await enabledButtons.count();
if (enabledCount > 0) {
// Click the first enabled date button
await enabledButtons.first().click();
// Wait for the section to appear
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
// Should either show no slots message OR show no slot buttons
// Wait a moment for API to return
await page.waitForTimeout(1000);
// If no availability is set, we'll see the "No available slots" message
const noSlotsMessage = page.getByText("No available slots for this date");
const isNoSlotsVisible = await noSlotsMessage.isVisible().catch(() => false);
if (!isNoSlotsVisible) {
// There might be some slots from shared state - just verify the section loads
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
}
} else {
// All dates are disabled - verify that disabled dates are shown
const disabledButtons = dateButtons.filter({ hasText: /disabled/ });
await expect(disabledButtons.first()).toBeDisabled();
}
});
});
test.describe("Booking Page - With Availability", () => {
test.beforeEach(async ({ page }) => {
await clearAuth(page);
// Login as admin to set availability
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await setAvailability(page, getTomorrowDateStr());
await clearAuth(page);
// Login as regular user
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
});
test("shows available slots when availability is set", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name to click the correct button
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date using the weekday name
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for "Available Slots" section to appear
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
// Wait for loading to finish (no "Loading slots..." text)
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
// Should see some slot buttons (look for any button with time-like pattern)
// The format might be "09:00" or "9:00 AM" depending on locale
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
});
test("clicking slot shows confirmation form", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for any slot to appear
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
// Click first slot
await slotButtons.first().click();
// Should show confirmation form
await expect(page.getByText("Confirm Booking")).toBeVisible();
await expect(page.getByRole("button", { name: "Book Appointment" })).toBeVisible();
});
test("can book an appointment with note", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for slots to load
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
// Click second slot (to avoid booking same slot as other tests)
await slotButtons.nth(1).click();
// Add a note
await page.fill("textarea", "Test booking note");
// Book
await page.getByRole("button", { name: "Book Appointment" }).click();
// Should show success message
await expect(page.getByText(/Appointment booked/)).toBeVisible();
});
test("booked slot disappears from available slots", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for slots to load
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
// Count initial slots
const initialCount = await slotButtons.count();
// Click any slot (3rd to avoid conflicts)
const slotToBook = slotButtons.nth(2);
const _slotText = await slotToBook.textContent();
await slotToBook.click();
// Book it
await page.getByRole("button", { name: "Book Appointment" }).click();
// Wait for booking form to disappear (indicates booking completed)
await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({
timeout: 10000,
});
// Wait for success message
await expect(page.getByText(/Appointment booked/)).toBeVisible();
// Should have one less slot now
const newCount = await slotButtons.count();
expect(newCount).toBe(initialCount - 1);
});
});
test.describe("Booking Page - Access Control", () => {
test("admin cannot access booking page", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/booking");
// Should be redirected away (to audit or home)
await expect(page).not.toHaveURL("/booking");
});
test("admin does not see Book link", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/audit");
await expect(page.getByRole("link", { name: "Book" })).not.toBeVisible();
});
test("unauthenticated user redirected to login", async ({ page }) => {
await clearAuth(page);
await page.goto("/booking");
await expect(page).toHaveURL("/login");
});
});
test.describe("Booking API", () => {
test("regular user can book via API", async ({ page, request }) => {
await clearAuth(page);
// Set up availability as admin
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const dateStr = getTomorrowDateStr();
await setAvailability(page, dateStr);
await clearAuth(page);
// Login as regular user
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
// Use 11:45 to avoid conflicts with other tests using 10:00
const response = await request.post(`${API_URL}/api/booking`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
slot_start: `${dateStr}T11:45:00Z`,
note: "API test booking",
},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.note).toBe("API test booking");
expect(data.status).toBe("booked");
}
});
test("admin cannot book via API", async ({ page, request }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const dateStr = getTomorrowDateStr();
await setAvailability(page, dateStr);
const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const response = await request.post(`${API_URL}/api/booking`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
slot_start: `${dateStr}T10:15:00Z`,
},
});
expect(response.status()).toBe(403);
}
});
});

View file

@ -61,8 +61,8 @@ test.describe("Price History - E2E", () => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
// Admin should be on audit page by default
await expect(page).toHaveURL("/audit");
// Admin should be on admin trades page by default
await expect(page).toHaveURL("/admin/trades");
// Prices nav link should be visible
await expect(page.getByRole("link", { name: "Prices" })).toBeVisible();

View file

@ -75,8 +75,8 @@ test.describe("Profile - Regular User Access", () => {
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
});
test("can navigate to profile page from counter", async ({ page }) => {
await page.goto("/");
test("can navigate to profile page from exchange", async ({ page }) => {
await page.goto("/exchange");
// Should see My Profile link
await expect(page.getByText("My Profile")).toBeVisible();
@ -86,8 +86,8 @@ test.describe("Profile - Regular User Access", () => {
await expect(page).toHaveURL("/profile");
});
test("can navigate to profile page from sum", async ({ page }) => {
await page.goto("/sum");
test("can navigate to profile page from trades", async ({ page }) => {
await page.goto("/trades");
// Should see My Profile link
await expect(page.getByText("My Profile")).toBeVisible();
@ -126,12 +126,12 @@ test.describe("Profile - Regular User Access", () => {
await expect(loginEmailInput).toBeDisabled();
});
test("navigation shows Counter, Sum, and My Profile", async ({ page }) => {
test("navigation shows Exchange, My Trades, and My Profile", async ({ page }) => {
await page.goto("/profile");
// Should see all nav items (Counter and Sum as links)
await expect(page.locator('a[href="/"]')).toBeVisible();
await expect(page.locator('a[href="/sum"]')).toBeVisible();
// Should see all nav items (Exchange and My Trades as links)
await expect(page.locator('a[href="/exchange"]')).toBeVisible();
await expect(page.locator('a[href="/trades"]')).toBeVisible();
// My Profile is the page title (h1) since we're on this page
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
});
@ -314,20 +314,20 @@ test.describe("Profile - Admin User Access", () => {
});
test("admin does not see My Profile link", async ({ page }) => {
await page.goto("/audit");
await page.goto("/admin/trades");
// Should be on audit page
await expect(page).toHaveURL("/audit");
// Should be on admin trades page
await expect(page).toHaveURL("/admin/trades");
// Should NOT see My Profile link
await expect(page.locator('a[href="/profile"]')).toHaveCount(0);
});
test("admin cannot access profile page - redirected to audit", async ({ page }) => {
test("admin cannot access profile page - redirected to admin trades", async ({ page }) => {
await page.goto("/profile");
// Should be redirected to audit
await expect(page).toHaveURL("/audit");
// Should be redirected to admin trades
await expect(page).toHaveURL("/admin/trades");
});
test("admin API call to profile returns 403", async ({ page, request }) => {