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:
2026-05-10 07:42:46 -04:00
parent 6fd8b0317e
commit 9e7d589c22
12 changed files with 355 additions and 9 deletions

34
e2e/fixtures/auth.ts Normal file
View 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
View 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(/&amp;/g, "&");
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`No magic link for ${email} within ${timeoutMs}ms`);
}