diff --git a/backend/main.py b/backend/main.py index bead725..7972749 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routes/booking.py b/backend/routes/booking.py index 1788ac6..b94b7e4 100644 --- a/backend/routes/booking.py +++ b/backend/routes/booking.py @@ -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, + ) + diff --git a/backend/tests/test_booking.py b/backend/tests/test_booking.py index e0b82e5..d3367dc 100644 --- a/backend/tests/test_booking.py +++ b/backend/tests/test_booking.py @@ -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 + diff --git a/frontend/app/appointments/page.tsx b/frontend/app/appointments/page.tsx new file mode 100644 index 0000000..37a118a --- /dev/null +++ b/frontend/app/appointments/page.tsx @@ -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([]); + const [isLoadingAppointments, setIsLoadingAppointments] = useState(true); + const [cancellingId, setCancellingId] = useState(null); + const [confirmCancelId, setConfirmCancelId] = useState(null); + const [error, setError] = useState(null); + + const fetchAppointments = useCallback(async () => { + try { + const data = await api.get("/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(`/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 ( +
+
+
+

Loading...

+
+
+ ); + } + + 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 ( +
+
+
+

My Appointments

+

+ View and manage your booked appointments +

+ + {error && ( +
+ {error} +
+ )} + + {isLoadingAppointments ? ( +

Loading appointments...

+ ) : appointments.length === 0 ? ( +
+

You don't have any appointments yet.

+ Book an appointment +
+ ) : ( + <> + {/* Upcoming Appointments */} + {upcomingAppointments.length > 0 && ( +
+

+ Upcoming ({upcomingAppointments.length}) +

+
+ {upcomingAppointments.map((apt) => { + const status = getStatusDisplay(apt.status); + return ( +
+
+
+
+ {formatDateTime(apt.slot_start)} +
+ {apt.note && ( +
+ {apt.note} +
+ )} + + {status.text} + +
+ + {apt.status === "booked" && ( +
+ {confirmCancelId === apt.id ? ( +
+ + +
+ ) : ( + + )} +
+ )} +
+
+ ); + })} +
+
+ )} + + {/* Past/Cancelled Appointments */} + {pastOrCancelledAppointments.length > 0 && ( +
+

+ Past & Cancelled ({pastOrCancelledAppointments.length}) +

+
+ {pastOrCancelledAppointments.map((apt) => { + const status = getStatusDisplay(apt.status); + return ( +
+
+ {formatDateTime(apt.slot_start)} +
+ {apt.note && ( +
+ {apt.note} +
+ )} + + {status.text} + +
+ ); + })} +
+
+ )} + + )} +
+
+ ); +} + diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index 136ca9d..06cf08d 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -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 }, ]; diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index 7c7b754..f8ee2c1 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -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; diff --git a/frontend/e2e/appointments.spec.ts b/frontend/e2e/appointments.spec.ts new file mode 100644 index 0000000..e01dc23 --- /dev/null +++ b/frontend/e2e/appointments.spec.ts @@ -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); + } + }); +}); + diff --git a/frontend/e2e/booking.spec.ts b/frontend/e2e/booking.spec.ts index 9888a50..fb95aa9 100644 --- a/frontend/e2e/booking.spec.ts +++ b/frontend/e2e/booking.spec.ts @@ -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", }, }); diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json index cbcc1fb..0dc2013 100644 --- a/frontend/test-results/.last-run.json +++ b/frontend/test-results/.last-run.json @@ -1,4 +1,10 @@ { - "status": "passed", - "failedTests": [] + "status": "failed", + "failedTests": [ + "647d672ac99574a52088-7123e0baf27b194c0b82", + "647d672ac99574a52088-a2e9f69e9c9ef92dc2bb", + "647d672ac99574a52088-f95d3555fba5395917f0", + "a3a10f2351dcc49e1cb9-ac24d656b0f11d39342d", + "50879ed375f8988ef978-7be9cdef76510785fb98" + ] } \ No newline at end of file diff --git a/frontend/test-results/availability-Availability--16504-s-can-add-availability-slot/error-context.md b/frontend/test-results/availability-Availability--16504-s-can-add-availability-slot/error-context.md new file mode 100644 index 0000000..76eaba8 --- /dev/null +++ b/frontend/test-results/availability-Availability--16504-s-can-add-availability-slot/error-context.md @@ -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] +``` \ No newline at end of file diff --git a/frontend/test-results/availability-Availability--5fe6c-cess-can-clear-availability/error-context.md b/frontend/test-results/availability-Availability--5fe6c-cess-can-clear-availability/error-context.md new file mode 100644 index 0000000..76eaba8 --- /dev/null +++ b/frontend/test-results/availability-Availability--5fe6c-cess-can-clear-availability/error-context.md @@ -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] +``` \ No newline at end of file diff --git a/frontend/test-results/availability-Availability--aa4bd-cess-can-add-multiple-slots/error-context.md b/frontend/test-results/availability-Availability--aa4bd-cess-can-add-multiple-slots/error-context.md new file mode 100644 index 0000000..76eaba8 --- /dev/null +++ b/frontend/test-results/availability-Availability--aa4bd-cess-can-add-multiple-slots/error-context.md @@ -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] +``` \ No newline at end of file diff --git a/frontend/test-results/booking-Booking-Page---Wit-0ce9d-ppears-from-available-slots/error-context.md b/frontend/test-results/booking-Booking-Page---Wit-0ce9d-ppears-from-available-slots/error-context.md new file mode 100644 index 0000000..3a786a0 --- /dev/null +++ b/frontend/test-results/booking-Booking-Page---Wit-0ce9d-ppears-from-available-slots/error-context.md @@ -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] +``` \ No newline at end of file diff --git a/frontend/test-results/counter-Counter---Authenticated-displays-counter-value/error-context.md b/frontend/test-results/counter-Counter---Authenticated-displays-counter-value/error-context.md new file mode 100644 index 0000000..38ad869 --- /dev/null +++ b/frontend/test-results/counter-Counter---Authenticated-displays-counter-value/error-context.md @@ -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 +``` \ No newline at end of file