Verify-suite work (this session):
- M package.json — added typecheck, test:e2e, verify scripts; added @playwright/test devDep - M pnpm-lock.yaml - M .gitignore — Playwright artifacts - M tsconfig.json — auto-modified by Next.js include path - ?? e2e/ — config + fixtures + 3 specs - ?? playwright.config.ts - ?? scripts/verify.sh, scripts/db-test-truncate.sql
This commit is contained in:
25
e2e/admin.spec.ts
Normal file
25
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { signInAs } from "./fixtures/auth";
|
||||
|
||||
test("admin signs in and reaches each admin section", async ({ page }) => {
|
||||
await signInAs(page, "admin", "/admin");
|
||||
await page.goto("/admin");
|
||||
await expect(page).toHaveURL(/\/admin\/bookings$/);
|
||||
|
||||
for (const [label, urlRe] of [
|
||||
["bookings", /\/admin\/bookings/],
|
||||
["services", /\/admin\/services/],
|
||||
["rooms", /\/admin\/rooms/],
|
||||
["therapists", /\/admin\/therapists/],
|
||||
] as const) {
|
||||
await page.getByRole("link", { name: new RegExp(`^${label}$`, "i") }).click();
|
||||
await expect(page).toHaveURL(urlRe);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-admin (therapist) hitting /admin sees the deny screen", async ({ page }) => {
|
||||
await signInAs(page, "therapist", "/admin");
|
||||
await page.goto("/admin");
|
||||
await expect(page.getByRole("heading", { name: /not authorized/i })).toBeVisible();
|
||||
});
|
||||
43
e2e/customer.spec.ts
Normal file
43
e2e/customer.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { signInAs } from "./fixtures/auth";
|
||||
|
||||
test("customer can browse and reach the booking confirm page", async ({ page }) => {
|
||||
// /book/confirm requires an authenticated session, so sign in first.
|
||||
await signInAs(page, "customer", "/");
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: /book a session/i })).toBeVisible();
|
||||
|
||||
// First service "Book" link → /book?serviceId=...
|
||||
const firstBook = page.locator('a[href^="/book?serviceId="]').first();
|
||||
await expect(firstBook).toBeVisible();
|
||||
await firstBook.click();
|
||||
await expect(page).toHaveURL(/\/book\?serviceId=/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
|
||||
// Pick a date a few days out so slots are likely available.
|
||||
const dateInput = page.locator('input[type="date"][name="date"]');
|
||||
await expect(dateInput).toBeVisible();
|
||||
const target = new Date();
|
||||
target.setDate(target.getDate() + 3);
|
||||
const iso = target.toISOString().slice(0, 10);
|
||||
await dateInput.fill(iso);
|
||||
await page.getByRole("button", { name: /show times/i }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`date=${iso}`));
|
||||
|
||||
// Grab the first slot's href and visit it directly.
|
||||
const slot = page.locator('a[href^="/book/confirm?"]').first();
|
||||
await expect(slot, "expected at least one available slot").toBeVisible();
|
||||
const slotHref = await slot.getAttribute("href");
|
||||
expect(slotHref).toMatch(/^\/book\/confirm\?/);
|
||||
await page.goto(slotHref!);
|
||||
|
||||
// Already authenticated, so we land on the confirm form (not /login).
|
||||
await expect(page).toHaveURL(/\/book\/confirm\?/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
|
||||
// /account/bookings should be reachable while signed in.
|
||||
await page.goto("/account/bookings");
|
||||
await expect(page).toHaveURL(/\/account\/bookings/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
});
|
||||
34
e2e/fixtures/auth.ts
Normal file
34
e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clearMailpit, waitForMagicLink } from "./mailpit";
|
||||
|
||||
export const ROLE_EMAIL = {
|
||||
admin: "admin@touchbase.local",
|
||||
therapist: "mei@touchbase.local",
|
||||
customer: "alex@example.com",
|
||||
} as const;
|
||||
|
||||
export type RoleKey = keyof typeof ROLE_EMAIL;
|
||||
|
||||
export async function signInAs(page: Page, role: RoleKey, callbackUrl = "/"): Promise<void> {
|
||||
await clearMailpit();
|
||||
const email = ROLE_EMAIL[role];
|
||||
|
||||
await page.goto(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
|
||||
await page.getByLabel("Email").fill(email);
|
||||
// Auth.js v5 server-action signIn() redirects to either our /login/check-email
|
||||
// page or its internal /api/auth/verify-request. Either is fine — the email
|
||||
// is sent regardless. Just wait for any nav off /login.
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !/\/login(\?|$)/.test(url.pathname + url.search)),
|
||||
page.getByRole("button", { name: /send sign-in link/i }).click(),
|
||||
]);
|
||||
|
||||
const link = await waitForMagicLink(email);
|
||||
await page.goto(link);
|
||||
|
||||
// After callback, NextAuth redirects to callbackUrl. Verify session cookie present.
|
||||
await expect.poll(async () => {
|
||||
const cookies = await page.context().cookies();
|
||||
return cookies.some((c) => /authjs.*session-token/i.test(c.name) || /next-auth.*session-token/i.test(c.name));
|
||||
}).toBeTruthy();
|
||||
}
|
||||
41
e2e/fixtures/mailpit.ts
Normal file
41
e2e/fixtures/mailpit.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
|
||||
|
||||
export async function clearMailpit(): Promise<void> {
|
||||
const res = await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`Mailpit clear failed: ${res.status}`);
|
||||
}
|
||||
|
||||
type MessageSummary = { ID: string; To: { Address: string }[]; Created: string };
|
||||
|
||||
async function listMessagesTo(email: string): Promise<MessageSummary[]> {
|
||||
const url = `${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${email}`)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Mailpit search failed: ${res.status}`);
|
||||
const data = (await res.json()) as { messages: MessageSummary[] };
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
async function getMessageBody(id: string): Promise<{ text: string; html: string }> {
|
||||
const res = await fetch(`${MAILPIT}/api/v1/message/${id}`);
|
||||
if (!res.ok) throw new Error(`Mailpit fetch failed: ${res.status}`);
|
||||
const data = (await res.json()) as { Text?: string; HTML?: string };
|
||||
return { text: data.Text ?? "", html: data.HTML ?? "" };
|
||||
}
|
||||
|
||||
export async function waitForMagicLink(email: string, opts: { timeoutMs?: number } = {}): Promise<string> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const linkRe = /https?:\/\/[^\s<>"]*\/api\/auth\/callback\/nodemailer[^\s<>"]*/i;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const messages = await listMessagesTo(email);
|
||||
if (messages.length > 0) {
|
||||
const newest = messages.sort((a, b) => b.Created.localeCompare(a.Created))[0];
|
||||
const { text, html } = await getMessageBody(newest.ID);
|
||||
const m = (text.match(linkRe) ?? html.match(linkRe))?.[0];
|
||||
if (m) return m.replace(/&/g, "&");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error(`No magic link for ${email} within ${timeoutMs}ms`);
|
||||
}
|
||||
27
e2e/therapist.spec.ts
Normal file
27
e2e/therapist.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { signInAs } from "./fixtures/auth";
|
||||
|
||||
test("therapist signs in and sees their dashboard", async ({ page }) => {
|
||||
await signInAs(page, "therapist", "/therapist");
|
||||
// /therapist redirects to /therapist/bookings.
|
||||
await page.goto("/therapist");
|
||||
await expect(page).toHaveURL(/\/therapist\/bookings$/);
|
||||
|
||||
// Top nav exposes "My schedule" → bookings, and "Availability".
|
||||
await expect(page.getByRole("link", { name: /^my schedule$/i })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /^availability$/i })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /^availability$/i }).click();
|
||||
await expect(page).toHaveURL(/\/therapist\/availability/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /^my schedule$/i }).click();
|
||||
await expect(page).toHaveURL(/\/therapist\/bookings/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test("non-therapist (customer) hitting /therapist sees the deny screen", async ({ page }) => {
|
||||
await signInAs(page, "customer", "/therapist");
|
||||
await page.goto("/therapist");
|
||||
await expect(page.getByRole("heading", { name: /therapists only/i })).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user