Repository Public
1import { expect, test } from "@playwright/test";2import { bookFirstSlot, expectAccessible, expectNoHorizontalScroll, psql, waitForMail } from "./helpers";34test.describe.configure({ mode: "serial" });56test("home lists the appointment types and is accessible", async ({ page }) => {7 await page.goto("/");8 await expect(page.getByRole("heading", { level: 1 })).toContainText("Camille Tremblay");9 await expect(page.getByRole("link", { name: /Rencontre de 30 minutes/ })).toBeVisible();10 await expectAccessible(page);11 await expectNoHorizontalScroll(page);12});1314test("a visitor submits a request: received, pending, never shown as confirmed", async ({ page }, info) => {15 const email = `visiteur-${info.project.name}@example.test`;16 const r = await bookFirstSlot(page, "rencontre-30", { name: "Alex Visiteur", email, reason: "Motif-confidentiel-ZZZ" });17 expect(r.status).toBe("pending");18 await expect(page.getByRole("heading", { name: "Demande reçue" })).toBeVisible();19 await expect(page.getByText("En attente d'approbation")).toBeVisible();20 await expect(page.getByText(/Rendez-vous confirmé/)).toHaveCount(0);21 await expect(page.getByRole("link", { name: /\.ics/ })).toHaveCount(0);22 await expectAccessible(page);23 // nothing confirmed in the database either24 expect(psql(`SELECT status FROM booking_requests r JOIN contacts c ON c.id = r.contact_id WHERE c.email = '${email}'`)).toBe("pending");25 expect(psql(`SELECT count(*) FROM bookings b JOIN contacts c ON c.id = b.contact_id WHERE c.email = '${email}'`)).toBe("0");26 // the worker really delivers the acknowledgement; it says "not confirmed" and never carries the reason or an invitation27 const mail = await waitForMail((m) => m.to === email && m.subject.startsWith("Demande reçue"));28 expect(mail.text).toContain("pas encore un rendez-vous confirmé");29 expect(mail.text + mail.html).not.toContain("ZZZ");30 expect(mail.ics ?? null).toBeNull();3132 // management link: pending state, and the held slot is no longer offered to someone else33 await page.goto(`/manage/${r.token}`);34 await expect(page.getByTestId("manage")).toHaveAttribute("data-state", "pending");35 await expectAccessible(page);36 await expectNoHorizontalScroll(page);37 const held = psql(`SELECT to_char(r.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS".000Z"') FROM booking_requests r JOIN contacts c ON c.id = r.contact_id WHERE c.email = '${email}'`);38 const day = held.slice(0, 10);39 const res = await page.request.get(`/api/v1/public/event-types/rencontre-30/slots?from=${day}&to=${day}`);40 expect(((await res.json()) as { slots: string[] }).slots).not.toContain(held);41});4243test("the booking page is usable with the keyboard and adapts to the visitor's time zone", async ({ browser }) => {44 const ctx = await browser.newContext({ timezoneId: "Europe/Paris", locale: "fr-FR" });45 const page = await ctx.newPage();46 await page.goto("/book/rencontre-30");47 await expect(page.getByText(/Paris/).first()).toBeVisible();48 const first = page.locator('[data-calendar] button:not([disabled])').first();49 await first.focus(); await page.keyboard.press("Enter");50 await expect(page.locator('ul[aria-label] button').first()).toBeVisible();51 // Toronto opens 9:00–17:00, i.e. 15:00–23:00 in Paris: every time shown is in the visitor's zone52 const times = await page.locator('ul[aria-label] button').allTextContents();53 expect(times.length).toBeGreaterThan(0);54 for (const t of times) expect(Number(t.trim().split(/\s|:/)[0])).toBeGreaterThanOrEqual(15);55 await expect(page.getByText(/à Toronto/)).toBeVisible();56 await expectAccessible(page);57 await ctx.close();58});5960test("server-side validation and anti-abuse on the public API", async ({ request }) => {61 const post = (data: Record<string, unknown>, headers: Record<string, string> = {}) => request.post("/api/v1/public/requests", { data, headers });62 const token = ((await (await request.get("/api/v1/public/form-token")).json()) as { formToken: string }).formToken;63 const base = { slug: "rencontre-30", start: "2030-01-07T15:00:00.000Z", name: "Bot", email: "bot@example.test", reason: "x", visitorTz: "America/Toronto", locale: "fr", idempotencyKey: crypto.randomUUID(), consent: true, formToken: token };64 await new Promise((r) => setTimeout(r, 2700)); // the form token refuses instant submissions in production65 expect((await post({ ...base, consent: false })).status()).toBe(400); // consent is mandatory66 expect((await post({ ...base, email: "not-an-email" })).status()).toBe(400);67 expect((await post({ ...base, formToken: "123.forged" })).status()).toBe(400); // forged form token68 expect((await post({ ...base, website: "http://spam" })).status()).toBe(400); // honeypot69 expect((await post(base, { origin: "https://evil.example" })).status()).toBe(403); // cross-site POST70 const far = await post(base); // outside the horizon71 expect(far.status()).toBe(409);72 const past = await post({ ...base, start: "2020-01-06T15:00:00.000Z", idempotencyKey: crypto.randomUUID() });73 expect(past.status()).toBe(409);74 expect(((await past.json()) as { error: { code: string } }).error.code).toBe("slot_past");75});7677test("invalid management link and unknown pages", async ({ page }) => {78 await page.goto("/manage/this-token-does-not-exist");79 await expect(page.getByTestId("manage-error")).toHaveAttribute("data-code", "link_invalid");80 await expect(page.getByText("Lien invalide")).toBeVisible();81 const res = await page.goto("/book/does-not-exist");82 expect(res!.status()).toBe(404);83 await expect(page.getByRole("heading", { name: "Page introuvable" })).toBeVisible();84});8586test("English interface", async ({ browser }) => {87 const ctx = await browser.newContext({ locale: "en-CA" });88 const page = await ctx.newPage();89 await page.goto("/");90 await expect(page.getByRole("heading", { level: 1 })).toContainText("Book a time with");91 await expect(page.locator("html")).toHaveAttribute("lang", "en-CA");92 await ctx.close();93});94