Phase 5: User appointments view and cancellation with UI and e2e tests

This commit is contained in:
counterweight 2025-12-21 00:24:16 +01:00
parent 8ff03a8ec3
commit 5108a620e7
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
14 changed files with 1539 additions and 4 deletions

View file

@ -48,4 +48,5 @@ app.include_router(invites_routes.router)
app.include_router(invites_routes.admin_router)
app.include_router(availability_routes.router)
app.include_router(booking_routes.router)
app.include_router(booking_routes.appointments_router)
app.include_router(meta_routes.router)

View file

@ -195,3 +195,86 @@ async def create_booking(
cancelled_at=appointment.cancelled_at,
)
# =============================================================================
# User's Appointments Endpoints
# =============================================================================
appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"])
@appointments_router.get("", response_model=list[AppointmentResponse])
async def get_my_appointments(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_APPOINTMENTS)),
) -> list[AppointmentResponse]:
"""Get the current user's appointments, sorted by date (upcoming first)."""
result = await db.execute(
select(Appointment)
.where(Appointment.user_id == current_user.id)
.order_by(Appointment.slot_start.desc())
)
appointments = result.scalars().all()
return [
AppointmentResponse(
id=apt.id,
user_id=apt.user_id,
user_email=current_user.email,
slot_start=apt.slot_start,
slot_end=apt.slot_end,
note=apt.note,
status=apt.status.value,
created_at=apt.created_at,
cancelled_at=apt.cancelled_at,
)
for apt in appointments
]
@appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
async def cancel_my_appointment(
appointment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)),
) -> AppointmentResponse:
"""Cancel one of the current user's appointments."""
# Get the appointment
result = await db.execute(
select(Appointment).where(Appointment.id == appointment_id)
)
appointment = result.scalar_one_or_none()
if not appointment:
raise HTTPException(status_code=404, detail="Appointment not found")
# Verify ownership
if appointment.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Cannot cancel another user's appointment")
# Check if already cancelled
if appointment.status != AppointmentStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel appointment with status '{appointment.status.value}'"
)
# Cancel the appointment
appointment.status = AppointmentStatus.CANCELLED_BY_USER
appointment.cancelled_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(appointment)
return AppointmentResponse(
id=appointment.id,
user_id=appointment.user_id,
user_email=current_user.email,
slot_start=appointment.slot_start,
slot_end=appointment.slot_end,
note=appointment.note,
status=appointment.status.value,
created_at=appointment.created_at,
cancelled_at=appointment.cancelled_at,
)

View file

@ -447,3 +447,212 @@ class TestBookingNoteValidation:
assert response.status_code == 200
assert response.json()["note"] == note
# =============================================================================
# User Appointments Tests
# =============================================================================
class TestUserAppointments:
"""Test user appointments endpoints."""
@pytest.mark.asyncio
async def test_get_my_appointments_empty(self, client_factory, regular_user):
"""Returns empty list when user has no appointments."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/appointments")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_get_my_appointments_with_bookings(self, client_factory, regular_user, admin_user):
"""Returns user's appointments."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books two slots
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "First"},
)
await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:15:00Z", "note": "Second"},
)
# Get appointments
response = await client.get("/api/appointments")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# Sorted by date descending
notes = [apt["note"] for apt in data]
assert "First" in notes
assert "Second" in notes
@pytest.mark.asyncio
async def test_admin_cannot_view_user_appointments(self, client_factory, admin_user):
"""Admin cannot access user appointments endpoint."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/appointments")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_appointments(self, client):
"""Unauthenticated user cannot view appointments."""
response = await client.get("/api/appointments")
assert response.status_code == 401
class TestCancelAppointment:
"""Test cancelling appointments."""
@pytest.mark.asyncio
async def test_cancel_own_appointment(self, client_factory, regular_user, admin_user):
"""User can cancel their own appointment."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Cancel
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled_by_user"
assert data["cancelled_at"] is not None
@pytest.mark.asyncio
async def test_cannot_cancel_others_appointment(self, client_factory, regular_user, alt_regular_user, admin_user):
"""User cannot cancel another user's appointment."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# First user books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Second user tries to cancel
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 403
assert "another user" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_cannot_cancel_nonexistent_appointment(self, client_factory, regular_user):
"""Returns 404 for non-existent appointment."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/appointments/99999/cancel")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_cannot_cancel_already_cancelled(self, client_factory, regular_user, admin_user):
"""Cannot cancel an already cancelled appointment."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books and cancels
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
await client.post(f"/api/appointments/{apt_id}/cancel")
# Try to cancel again
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]
@pytest.mark.asyncio
async def test_admin_cannot_use_user_cancel_endpoint(self, client_factory, admin_user):
"""Admin cannot use user cancel endpoint."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post("/api/appointments/1/cancel")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_cancelled_slot_becomes_available(self, client_factory, regular_user, admin_user):
"""After cancelling, the slot becomes available again."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "09:30:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Check slots - should have 1 slot left (09:15)
slots_response = await client.get(
"/api/booking/slots",
params={"date": str(tomorrow())},
)
assert len(slots_response.json()["slots"]) == 1
# Cancel
await client.post(f"/api/appointments/{apt_id}/cancel")
# Check slots - should have 2 slots now
slots_response = await client.get(
"/api/booking/slots",
params={"date": str(tomorrow())},
)
assert len(slots_response.json()["slots"]) == 2

View file

@ -0,0 +1,290 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
// Helper to format datetime
function formatDateTime(isoString: string): string {
const d = new Date(isoString);
return d.toLocaleString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
// Helper to get status display
function getStatusDisplay(status: string): { text: string; color: string } {
switch (status) {
case "booked":
return { text: "Booked", color: "#28a745" };
case "cancelled_by_user":
return { text: "Cancelled by you", color: "#dc3545" };
case "cancelled_by_admin":
return { text: "Cancelled by admin", color: "#dc3545" };
default:
return { text: status, color: "#666" };
}
}
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) {
if (err instanceof Error) {
setError(err.message);
} else {
setError("Failed to cancel appointment");
}
} finally {
setCancellingId(null);
}
};
if (isLoading) {
return (
<div style={sharedStyles.pageContainer}>
<Header currentPage="appointments" />
<main style={sharedStyles.mainContent}>
<p>Loading...</p>
</main>
</div>
);
}
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 (
<div style={sharedStyles.pageContainer}>
<Header currentPage="appointments" />
<main style={sharedStyles.mainContent}>
<h1 style={{ marginBottom: "0.5rem" }}>My Appointments</h1>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
View and manage your booked appointments
</p>
{error && (
<div style={{
background: "#f8d7da",
border: "1px solid #f5c6cb",
color: "#721c24",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
}}>
{error}
</div>
)}
{isLoadingAppointments ? (
<p>Loading appointments...</p>
) : appointments.length === 0 ? (
<div style={{
textAlign: "center",
padding: "2rem",
color: "#666",
}}>
<p>You don&apos;t have any appointments yet.</p>
<a href="/booking" style={{ color: "#0070f3" }}>Book an appointment</a>
</div>
) : (
<>
{/* Upcoming Appointments */}
{upcomingAppointments.length > 0 && (
<div style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem" }}>
Upcoming ({upcomingAppointments.length})
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{upcomingAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
return (
<div
key={apt.id}
style={{
border: "1px solid #ddd",
borderRadius: "8px",
padding: "1rem",
background: "#fff",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && (
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
{apt.note}
</div>
)}
<span style={{
color: status.color,
fontSize: "0.75rem",
fontWeight: 500,
}}>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div>
{confirmCancelId === apt.id ? (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => handleCancel(apt.id)}
disabled={cancellingId === apt.id}
style={{
padding: "0.25rem 0.75rem",
background: "#dc3545",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: cancellingId === apt.id ? "not-allowed" : "pointer",
fontSize: "0.75rem",
}}
>
{cancellingId === apt.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={{
padding: "0.25rem 0.75rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.75rem",
}}
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmCancelId(apt.id)}
style={{
padding: "0.25rem 0.75rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.75rem",
color: "#666",
}}
>
Cancel
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Past/Cancelled Appointments */}
{pastOrCancelledAppointments.length > 0 && (
<div>
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem", color: "#666" }}>
Past & Cancelled ({pastOrCancelledAppointments.length})
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{pastOrCancelledAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
return (
<div
key={apt.id}
style={{
border: "1px solid #eee",
borderRadius: "8px",
padding: "1rem",
background: "#fafafa",
opacity: 0.8,
}}
>
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && (
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
{apt.note}
</div>
)}
<span style={{
color: status.color,
fontSize: "0.75rem",
fontWeight: 500,
}}>
{status.text}
</span>
</div>
);
})}
</div>
</div>
)}
</>
)}
</main>
</div>
);
}

View file

@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json";
const { ADMIN, REGULAR } = constants.roles;
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "audit" | "admin-invites" | "admin-availability";
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "appointments" | "audit" | "admin-invites" | "admin-availability";
interface HeaderProps {
currentPage: PageId;
@ -25,6 +25,7 @@ const REGULAR_NAV_ITEMS: NavItem[] = [
{ id: "counter", label: "Counter", href: "/" },
{ id: "sum", label: "Sum", href: "/sum" },
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
];

View file

@ -396,6 +396,46 @@ export interface paths {
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/meta/constants": {
parameters: {
query?: never;
@ -1484,6 +1524,57 @@ export interface operations {
};
};
};
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_constants_api_meta_constants_get: {
parameters: {
query?: never;

View file

@ -0,0 +1,278 @@
import { test, expect, Page } from "@playwright/test";
/**
* Appointments Page E2E Tests
*
* Tests for viewing and cancelling user appointments.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Required environment variable ${name} is not set.`);
}
return value;
}
const REGULAR_USER = {
email: getRequiredEnv("DEV_USER_EMAIL"),
password: getRequiredEnv("DEV_USER_PASSWORD"),
};
const ADMIN_USER = {
email: getRequiredEnv("DEV_ADMIN_EMAIL"),
password: getRequiredEnv("DEV_ADMIN_PASSWORD"),
};
async function clearAuth(page: Page) {
await page.context().clearCookies();
}
async function loginUser(page: Page, email: string, password: string) {
await page.goto("/login");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
}
// Helper to format date as YYYY-MM-DD in local timezone
function formatDateLocal(d: Date): string {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function getTomorrowDateStr(): string {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return formatDateLocal(tomorrow);
}
// 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
await expect(page.getByText("Booked", { exact: true })).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

@ -315,13 +315,14 @@ test.describe("Booking API", () => {
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}T10:00:00Z`,
slot_start: `${dateStr}T11:45:00Z`,
note: "API test booking",
},
});

View file

@ -1,4 +1,10 @@
{
"status": "passed",
"failedTests": []
"status": "failed",
"failedTests": [
"647d672ac99574a52088-7123e0baf27b194c0b82",
"647d672ac99574a52088-a2e9f69e9c9ef92dc2bb",
"647d672ac99574a52088-f95d3555fba5395917f0",
"a3a10f2351dcc49e1cb9-ac24d656b0f11d39342d",
"50879ed375f8988ef978-7be9cdef76510785fb98"
]
}

View file

@ -0,0 +1,123 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- link "Audit" [ref=e6] [cursor=pointer]:
- /url: /audit
- generic [ref=e7]:
- text: •
- link "Invites" [ref=e8] [cursor=pointer]:
- /url: /admin/invites
- generic [ref=e9]: •Availability
- generic [ref=e10]:
- generic [ref=e11]: admin@example.com
- button "Sign out" [ref=e12] [cursor=pointer]
- generic [ref=e14]:
- generic [ref=e16]:
- heading "Availability" [level=1] [ref=e17]
- paragraph [ref=e18]: Configure your available time slots for the next 30 days
- generic [ref=e19]:
- generic [ref=e20] [cursor=pointer]:
- generic [ref=e21]:
- generic [ref=e22]: Mon, Dec 22
- button "⎘" [ref=e23]
- generic [ref=e25]: 09:00 - 12:00
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: Tue, Dec 23
- generic [ref=e30]: No availability
- generic [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Wed, Dec 24
- generic [ref=e35]: No availability
- generic [ref=e36] [cursor=pointer]:
- generic [ref=e38]: Thu, Dec 25
- generic [ref=e40]: No availability
- generic [ref=e41] [cursor=pointer]:
- generic [ref=e43]: Fri, Dec 26
- generic [ref=e45]: No availability
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e48]: Sat, Dec 27
- generic [ref=e50]: No availability
- generic [ref=e51] [cursor=pointer]:
- generic [ref=e53]: Sun, Dec 28
- generic [ref=e55]: No availability
- generic [ref=e56] [cursor=pointer]:
- generic [ref=e58]: Mon, Dec 29
- generic [ref=e60]: No availability
- generic [ref=e61] [cursor=pointer]:
- generic [ref=e63]: Tue, Dec 30
- generic [ref=e65]: No availability
- generic [ref=e66] [cursor=pointer]:
- generic [ref=e68]: Wed, Dec 31
- generic [ref=e70]: No availability
- generic [ref=e71] [cursor=pointer]:
- generic [ref=e73]: Thu, Jan 1
- generic [ref=e75]: No availability
- generic [ref=e76] [cursor=pointer]:
- generic [ref=e78]: Fri, Jan 2
- generic [ref=e80]: No availability
- generic [ref=e81] [cursor=pointer]:
- generic [ref=e83]: Sat, Jan 3
- generic [ref=e85]: No availability
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Sun, Jan 4
- generic [ref=e90]: No availability
- generic [ref=e91] [cursor=pointer]:
- generic [ref=e93]: Mon, Jan 5
- generic [ref=e95]: No availability
- generic [ref=e96] [cursor=pointer]:
- generic [ref=e98]: Tue, Jan 6
- generic [ref=e100]: No availability
- generic [ref=e101] [cursor=pointer]:
- generic [ref=e103]: Wed, Jan 7
- generic [ref=e105]: No availability
- generic [ref=e106] [cursor=pointer]:
- generic [ref=e108]: Thu, Jan 8
- generic [ref=e110]: No availability
- generic [ref=e111] [cursor=pointer]:
- generic [ref=e113]: Fri, Jan 9
- generic [ref=e115]: No availability
- generic [ref=e116] [cursor=pointer]:
- generic [ref=e118]: Sat, Jan 10
- generic [ref=e120]: No availability
- generic [ref=e121] [cursor=pointer]:
- generic [ref=e123]: Sun, Jan 11
- generic [ref=e125]: No availability
- generic [ref=e126] [cursor=pointer]:
- generic [ref=e128]: Mon, Jan 12
- generic [ref=e130]: No availability
- generic [ref=e131] [cursor=pointer]:
- generic [ref=e133]: Tue, Jan 13
- generic [ref=e135]: No availability
- generic [ref=e136] [cursor=pointer]:
- generic [ref=e138]: Wed, Jan 14
- generic [ref=e140]: No availability
- generic [ref=e141] [cursor=pointer]:
- generic [ref=e143]: Thu, Jan 15
- generic [ref=e145]: No availability
- generic [ref=e146] [cursor=pointer]:
- generic [ref=e148]: Fri, Jan 16
- generic [ref=e150]: No availability
- generic [ref=e151] [cursor=pointer]:
- generic [ref=e153]: Sat, Jan 17
- generic [ref=e155]: No availability
- generic [ref=e156] [cursor=pointer]:
- generic [ref=e158]: Sun, Jan 18
- generic [ref=e160]: No availability
- generic [ref=e161] [cursor=pointer]:
- generic [ref=e163]: Mon, Jan 19
- generic [ref=e165]: No availability
- generic [ref=e166] [cursor=pointer]:
- generic [ref=e168]: Tue, Jan 20
- generic [ref=e170]: No availability
- status [ref=e171]:
- generic [ref=e172]:
- img [ref=e174]
- generic [ref=e176]:
- text: Static route
- button "Hide static indicator" [ref=e177] [cursor=pointer]:
- img [ref=e178]
- alert [ref=e181]
```

View file

@ -0,0 +1,123 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- link "Audit" [ref=e6] [cursor=pointer]:
- /url: /audit
- generic [ref=e7]:
- text: •
- link "Invites" [ref=e8] [cursor=pointer]:
- /url: /admin/invites
- generic [ref=e9]: •Availability
- generic [ref=e10]:
- generic [ref=e11]: admin@example.com
- button "Sign out" [ref=e12] [cursor=pointer]
- generic [ref=e14]:
- generic [ref=e16]:
- heading "Availability" [level=1] [ref=e17]
- paragraph [ref=e18]: Configure your available time slots for the next 30 days
- generic [ref=e19]:
- generic [ref=e20] [cursor=pointer]:
- generic [ref=e21]:
- generic [ref=e22]: Mon, Dec 22
- button "⎘" [ref=e23]
- generic [ref=e25]: 09:00 - 12:00
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: Tue, Dec 23
- generic [ref=e30]: No availability
- generic [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Wed, Dec 24
- generic [ref=e35]: No availability
- generic [ref=e36] [cursor=pointer]:
- generic [ref=e38]: Thu, Dec 25
- generic [ref=e40]: No availability
- generic [ref=e41] [cursor=pointer]:
- generic [ref=e43]: Fri, Dec 26
- generic [ref=e45]: No availability
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e48]: Sat, Dec 27
- generic [ref=e50]: No availability
- generic [ref=e51] [cursor=pointer]:
- generic [ref=e53]: Sun, Dec 28
- generic [ref=e55]: No availability
- generic [ref=e56] [cursor=pointer]:
- generic [ref=e58]: Mon, Dec 29
- generic [ref=e60]: No availability
- generic [ref=e61] [cursor=pointer]:
- generic [ref=e63]: Tue, Dec 30
- generic [ref=e65]: No availability
- generic [ref=e66] [cursor=pointer]:
- generic [ref=e68]: Wed, Dec 31
- generic [ref=e70]: No availability
- generic [ref=e71] [cursor=pointer]:
- generic [ref=e73]: Thu, Jan 1
- generic [ref=e75]: No availability
- generic [ref=e76] [cursor=pointer]:
- generic [ref=e78]: Fri, Jan 2
- generic [ref=e80]: No availability
- generic [ref=e81] [cursor=pointer]:
- generic [ref=e83]: Sat, Jan 3
- generic [ref=e85]: No availability
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Sun, Jan 4
- generic [ref=e90]: No availability
- generic [ref=e91] [cursor=pointer]:
- generic [ref=e93]: Mon, Jan 5
- generic [ref=e95]: No availability
- generic [ref=e96] [cursor=pointer]:
- generic [ref=e98]: Tue, Jan 6
- generic [ref=e100]: No availability
- generic [ref=e101] [cursor=pointer]:
- generic [ref=e103]: Wed, Jan 7
- generic [ref=e105]: No availability
- generic [ref=e106] [cursor=pointer]:
- generic [ref=e108]: Thu, Jan 8
- generic [ref=e110]: No availability
- generic [ref=e111] [cursor=pointer]:
- generic [ref=e113]: Fri, Jan 9
- generic [ref=e115]: No availability
- generic [ref=e116] [cursor=pointer]:
- generic [ref=e118]: Sat, Jan 10
- generic [ref=e120]: No availability
- generic [ref=e121] [cursor=pointer]:
- generic [ref=e123]: Sun, Jan 11
- generic [ref=e125]: No availability
- generic [ref=e126] [cursor=pointer]:
- generic [ref=e128]: Mon, Jan 12
- generic [ref=e130]: No availability
- generic [ref=e131] [cursor=pointer]:
- generic [ref=e133]: Tue, Jan 13
- generic [ref=e135]: No availability
- generic [ref=e136] [cursor=pointer]:
- generic [ref=e138]: Wed, Jan 14
- generic [ref=e140]: No availability
- generic [ref=e141] [cursor=pointer]:
- generic [ref=e143]: Thu, Jan 15
- generic [ref=e145]: No availability
- generic [ref=e146] [cursor=pointer]:
- generic [ref=e148]: Fri, Jan 16
- generic [ref=e150]: No availability
- generic [ref=e151] [cursor=pointer]:
- generic [ref=e153]: Sat, Jan 17
- generic [ref=e155]: No availability
- generic [ref=e156] [cursor=pointer]:
- generic [ref=e158]: Sun, Jan 18
- generic [ref=e160]: No availability
- generic [ref=e161] [cursor=pointer]:
- generic [ref=e163]: Mon, Jan 19
- generic [ref=e165]: No availability
- generic [ref=e166] [cursor=pointer]:
- generic [ref=e168]: Tue, Jan 20
- generic [ref=e170]: No availability
- status [ref=e171]:
- generic [ref=e172]:
- img [ref=e174]
- generic [ref=e176]:
- text: Static route
- button "Hide static indicator" [ref=e177] [cursor=pointer]:
- img [ref=e178]
- alert [ref=e181]
```

View file

@ -0,0 +1,123 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- link "Audit" [ref=e6] [cursor=pointer]:
- /url: /audit
- generic [ref=e7]:
- text: •
- link "Invites" [ref=e8] [cursor=pointer]:
- /url: /admin/invites
- generic [ref=e9]: •Availability
- generic [ref=e10]:
- generic [ref=e11]: admin@example.com
- button "Sign out" [ref=e12] [cursor=pointer]
- generic [ref=e14]:
- generic [ref=e16]:
- heading "Availability" [level=1] [ref=e17]
- paragraph [ref=e18]: Configure your available time slots for the next 30 days
- generic [ref=e19]:
- generic [ref=e20] [cursor=pointer]:
- generic [ref=e21]:
- generic [ref=e22]: Mon, Dec 22
- button "⎘" [ref=e23]
- generic [ref=e25]: 09:00 - 12:00
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: Tue, Dec 23
- generic [ref=e30]: No availability
- generic [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Wed, Dec 24
- generic [ref=e35]: No availability
- generic [ref=e36] [cursor=pointer]:
- generic [ref=e38]: Thu, Dec 25
- generic [ref=e40]: No availability
- generic [ref=e41] [cursor=pointer]:
- generic [ref=e43]: Fri, Dec 26
- generic [ref=e45]: No availability
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e48]: Sat, Dec 27
- generic [ref=e50]: No availability
- generic [ref=e51] [cursor=pointer]:
- generic [ref=e53]: Sun, Dec 28
- generic [ref=e55]: No availability
- generic [ref=e56] [cursor=pointer]:
- generic [ref=e58]: Mon, Dec 29
- generic [ref=e60]: No availability
- generic [ref=e61] [cursor=pointer]:
- generic [ref=e63]: Tue, Dec 30
- generic [ref=e65]: No availability
- generic [ref=e66] [cursor=pointer]:
- generic [ref=e68]: Wed, Dec 31
- generic [ref=e70]: No availability
- generic [ref=e71] [cursor=pointer]:
- generic [ref=e73]: Thu, Jan 1
- generic [ref=e75]: No availability
- generic [ref=e76] [cursor=pointer]:
- generic [ref=e78]: Fri, Jan 2
- generic [ref=e80]: No availability
- generic [ref=e81] [cursor=pointer]:
- generic [ref=e83]: Sat, Jan 3
- generic [ref=e85]: No availability
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Sun, Jan 4
- generic [ref=e90]: No availability
- generic [ref=e91] [cursor=pointer]:
- generic [ref=e93]: Mon, Jan 5
- generic [ref=e95]: No availability
- generic [ref=e96] [cursor=pointer]:
- generic [ref=e98]: Tue, Jan 6
- generic [ref=e100]: No availability
- generic [ref=e101] [cursor=pointer]:
- generic [ref=e103]: Wed, Jan 7
- generic [ref=e105]: No availability
- generic [ref=e106] [cursor=pointer]:
- generic [ref=e108]: Thu, Jan 8
- generic [ref=e110]: No availability
- generic [ref=e111] [cursor=pointer]:
- generic [ref=e113]: Fri, Jan 9
- generic [ref=e115]: No availability
- generic [ref=e116] [cursor=pointer]:
- generic [ref=e118]: Sat, Jan 10
- generic [ref=e120]: No availability
- generic [ref=e121] [cursor=pointer]:
- generic [ref=e123]: Sun, Jan 11
- generic [ref=e125]: No availability
- generic [ref=e126] [cursor=pointer]:
- generic [ref=e128]: Mon, Jan 12
- generic [ref=e130]: No availability
- generic [ref=e131] [cursor=pointer]:
- generic [ref=e133]: Tue, Jan 13
- generic [ref=e135]: No availability
- generic [ref=e136] [cursor=pointer]:
- generic [ref=e138]: Wed, Jan 14
- generic [ref=e140]: No availability
- generic [ref=e141] [cursor=pointer]:
- generic [ref=e143]: Thu, Jan 15
- generic [ref=e145]: No availability
- generic [ref=e146] [cursor=pointer]:
- generic [ref=e148]: Fri, Jan 16
- generic [ref=e150]: No availability
- generic [ref=e151] [cursor=pointer]:
- generic [ref=e153]: Sat, Jan 17
- generic [ref=e155]: No availability
- generic [ref=e156] [cursor=pointer]:
- generic [ref=e158]: Sun, Jan 18
- generic [ref=e160]: No availability
- generic [ref=e161] [cursor=pointer]:
- generic [ref=e163]: Mon, Jan 19
- generic [ref=e165]: No availability
- generic [ref=e166] [cursor=pointer]:
- generic [ref=e168]: Tue, Jan 20
- generic [ref=e170]: No availability
- status [ref=e171]:
- generic [ref=e172]:
- img [ref=e174]
- generic [ref=e176]:
- text: Static route
- button "Hide static indicator" [ref=e177] [cursor=pointer]:
- img [ref=e178]
- alert [ref=e181]
```

View file

@ -0,0 +1,160 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- link "Counter" [ref=e6] [cursor=pointer]:
- /url: /
- generic [ref=e7]:
- text: •
- link "Sum" [ref=e8] [cursor=pointer]:
- /url: /sum
- generic [ref=e9]: •Book
- generic [ref=e10]:
- text: •
- link "Appointments" [ref=e11] [cursor=pointer]:
- /url: /appointments
- generic [ref=e12]:
- text: •
- link "My Invites" [ref=e13] [cursor=pointer]:
- /url: /invites
- generic [ref=e14]:
- text: •
- link "My Profile" [ref=e15] [cursor=pointer]:
- /url: /profile
- generic [ref=e16]:
- generic [ref=e17]: user@example.com
- button "Sign out" [ref=e18] [cursor=pointer]
- main [ref=e19]:
- heading "Book an Appointment" [level=1] [ref=e20]
- paragraph [ref=e21]: Select a date to see available 15-minute slots
- generic [ref=e22]: "Request failed: 409"
- generic [ref=e23]:
- heading "Select a Date" [level=2] [ref=e24]
- generic [ref=e25]:
- button "Mon Dec 22" [ref=e26] [cursor=pointer]:
- generic [ref=e27]: Mon
- generic [ref=e28]: Dec 22
- button "Tue Dec 23" [ref=e29] [cursor=pointer]:
- generic [ref=e30]: Tue
- generic [ref=e31]: Dec 23
- button "Wed Dec 24" [ref=e32] [cursor=pointer]:
- generic [ref=e33]: Wed
- generic [ref=e34]: Dec 24
- button "Thu Dec 25" [ref=e35] [cursor=pointer]:
- generic [ref=e36]: Thu
- generic [ref=e37]: Dec 25
- button "Fri Dec 26" [ref=e38] [cursor=pointer]:
- generic [ref=e39]: Fri
- generic [ref=e40]: Dec 26
- button "Sat Dec 27" [ref=e41] [cursor=pointer]:
- generic [ref=e42]: Sat
- generic [ref=e43]: Dec 27
- button "Sun Dec 28" [ref=e44] [cursor=pointer]:
- generic [ref=e45]: Sun
- generic [ref=e46]: Dec 28
- button "Mon Dec 29" [ref=e47] [cursor=pointer]:
- generic [ref=e48]: Mon
- generic [ref=e49]: Dec 29
- button "Tue Dec 30" [ref=e50] [cursor=pointer]:
- generic [ref=e51]: Tue
- generic [ref=e52]: Dec 30
- button "Wed Dec 31" [ref=e53] [cursor=pointer]:
- generic [ref=e54]: Wed
- generic [ref=e55]: Dec 31
- button "Thu Jan 1" [ref=e56] [cursor=pointer]:
- generic [ref=e57]: Thu
- generic [ref=e58]: Jan 1
- button "Fri Jan 2" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: Fri
- generic [ref=e61]: Jan 2
- button "Sat Jan 3" [ref=e62] [cursor=pointer]:
- generic [ref=e63]: Sat
- generic [ref=e64]: Jan 3
- button "Sun Jan 4" [ref=e65] [cursor=pointer]:
- generic [ref=e66]: Sun
- generic [ref=e67]: Jan 4
- button "Mon Jan 5" [ref=e68] [cursor=pointer]:
- generic [ref=e69]: Mon
- generic [ref=e70]: Jan 5
- button "Tue Jan 6" [ref=e71] [cursor=pointer]:
- generic [ref=e72]: Tue
- generic [ref=e73]: Jan 6
- button "Wed Jan 7" [ref=e74] [cursor=pointer]:
- generic [ref=e75]: Wed
- generic [ref=e76]: Jan 7
- button "Thu Jan 8" [ref=e77] [cursor=pointer]:
- generic [ref=e78]: Thu
- generic [ref=e79]: Jan 8
- button "Fri Jan 9" [ref=e80] [cursor=pointer]:
- generic [ref=e81]: Fri
- generic [ref=e82]: Jan 9
- button "Sat Jan 10" [ref=e83] [cursor=pointer]:
- generic [ref=e84]: Sat
- generic [ref=e85]: Jan 10
- button "Sun Jan 11" [ref=e86] [cursor=pointer]:
- generic [ref=e87]: Sun
- generic [ref=e88]: Jan 11
- button "Mon Jan 12" [ref=e89] [cursor=pointer]:
- generic [ref=e90]: Mon
- generic [ref=e91]: Jan 12
- button "Tue Jan 13" [ref=e92] [cursor=pointer]:
- generic [ref=e93]: Tue
- generic [ref=e94]: Jan 13
- button "Wed Jan 14" [ref=e95] [cursor=pointer]:
- generic [ref=e96]: Wed
- generic [ref=e97]: Jan 14
- button "Thu Jan 15" [ref=e98] [cursor=pointer]:
- generic [ref=e99]: Thu
- generic [ref=e100]: Jan 15
- button "Fri Jan 16" [ref=e101] [cursor=pointer]:
- generic [ref=e102]: Fri
- generic [ref=e103]: Jan 16
- button "Sat Jan 17" [ref=e104] [cursor=pointer]:
- generic [ref=e105]: Sat
- generic [ref=e106]: Jan 17
- button "Sun Jan 18" [ref=e107] [cursor=pointer]:
- generic [ref=e108]: Sun
- generic [ref=e109]: Jan 18
- button "Mon Jan 19" [ref=e110] [cursor=pointer]:
- generic [ref=e111]: Mon
- generic [ref=e112]: Jan 19
- button "Tue Jan 20" [ref=e113] [cursor=pointer]:
- generic [ref=e114]: Tue
- generic [ref=e115]: Jan 20
- generic [ref=e116]:
- heading "Available Slots for Monday, December 22" [level=2] [ref=e117]
- generic [ref=e118]:
- button "10:00" [ref=e119] [cursor=pointer]
- button "10:45" [ref=e120] [cursor=pointer]
- button "11:00" [ref=e121] [cursor=pointer]
- button "11:15" [ref=e122] [cursor=pointer]
- button "11:30" [ref=e123] [cursor=pointer]
- button "11:45" [ref=e124] [cursor=pointer]
- button "12:00" [ref=e125] [cursor=pointer]
- button "12:15" [ref=e126] [cursor=pointer]
- button "12:30" [ref=e127] [cursor=pointer]
- button "12:45" [ref=e128] [cursor=pointer]
- generic [ref=e129]:
- heading "Confirm Booking" [level=3] [ref=e130]
- paragraph [ref=e131]:
- strong [ref=e132]: "Time:"
- text: 11:00 - 11:15
- generic [ref=e133]:
- generic [ref=e134]: Note (optional, max 144 chars)
- textbox "Add a note about your appointment..." [ref=e135]
- generic [ref=e136]: 0/144
- generic [ref=e137]:
- button "Book Appointment" [ref=e138] [cursor=pointer]
- button "Cancel" [ref=e139] [cursor=pointer]
- status [ref=e140]:
- generic [ref=e141]:
- img [ref=e143]
- generic [ref=e145]:
- text: Static route
- button "Hide static indicator" [ref=e146] [cursor=pointer]:
- img [ref=e147]
- alert [ref=e150]
```

View file

@ -0,0 +1,46 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- status [ref=e2]:
- generic [ref=e3]:
- img [ref=e5]
- generic [ref=e7]:
- text: Static route
- button "Hide static indicator" [ref=e8] [cursor=pointer]:
- img [ref=e9]
- alert [ref=e12]: ...
- main [ref=e13]:
- generic [ref=e14]:
- generic [ref=e15]:
- generic [ref=e16]: Counter
- generic [ref=e17]:
- text: •
- link "Sum" [ref=e18] [cursor=pointer]:
- /url: /sum
- generic [ref=e19]:
- text: •
- link "Book" [ref=e20] [cursor=pointer]:
- /url: /booking
- generic [ref=e21]:
- text: •
- link "Appointments" [ref=e22] [cursor=pointer]:
- /url: /appointments
- generic [ref=e23]:
- text: •
- link "My Invites" [ref=e24] [cursor=pointer]:
- /url: /invites
- generic [ref=e25]:
- text: •
- link "My Profile" [ref=e26] [cursor=pointer]:
- /url: /profile
- generic [ref=e27]:
- generic [ref=e28]: counter-1766272960115-d79hw5@example.com
- button "Sign out" [ref=e29] [cursor=pointer]
- generic [ref=e31]:
- generic [ref=e32]: Current Count
- heading "0" [level=1] [ref=e33]
- button "+ Increment" [ref=e34] [cursor=pointer]:
- generic [ref=e35]: +
- text: Increment
```