From 9e7d589c226d274fb5eec3bba75f86ff6d1fcc88 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Sun, 10 May 2026 07:42:46 -0400 Subject: [PATCH] =?UTF-8?q?=20=20Verify-suite=20work=20(this=20session):?= =?UTF-8?q?=20=20=20-=20M=20=20package.json=20=E2=80=94=20added=20typechec?= =?UTF-8?q?k,=20test:e2e,=20verify=20scripts;=20added=20@playwright/test?= =?UTF-8?q?=20devDep=20=20=20-=20M=20=20pnpm-lock.yaml=20=20=20-=20M=20=20?= =?UTF-8?q?.gitignore=20=E2=80=94=20Playwright=20artifacts=20=20=20-=20M?= =?UTF-8?q?=20=20tsconfig.json=20=E2=80=94=20auto-modified=20by=20Next.js?= =?UTF-8?q?=20include=20path=20=20=20-=20=3F=3F=20=20e2e/=20=E2=80=94=20co?= =?UTF-8?q?nfig=20+=20fixtures=20+=203=20specs=20=20=20-=20=3F=3F=20=20pla?= =?UTF-8?q?ywright.config.ts=20=20=20-=20=3F=3F=20=20scripts/verify.sh,=20?= =?UTF-8?q?scripts/db-test-truncate.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/admin.spec.ts | 25 +++++++++++++ e2e/customer.spec.ts | 43 ++++++++++++++++++++++ e2e/fixtures/auth.ts | 34 ++++++++++++++++++ e2e/fixtures/mailpit.ts | 41 +++++++++++++++++++++ e2e/therapist.spec.ts | 27 ++++++++++++++ package.json | 4 +++ playwright.config.ts | 35 ++++++++++++++++++ pnpm-lock.yaml | 49 ++++++++++++++++++++++--- ruvector.db | Bin 0 -> 1589248 bytes scripts/db-test-truncate.sql | 22 ++++++++++++ scripts/verify.sh | 67 +++++++++++++++++++++++++++++++++++ tsconfig.json | 17 ++++++--- 12 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 e2e/admin.spec.ts create mode 100644 e2e/customer.spec.ts create mode 100644 e2e/fixtures/auth.ts create mode 100644 e2e/fixtures/mailpit.ts create mode 100644 e2e/therapist.spec.ts create mode 100644 playwright.config.ts create mode 100644 ruvector.db create mode 100644 scripts/db-test-truncate.sql create mode 100755 scripts/verify.sh diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..eabd16e --- /dev/null +++ b/e2e/admin.spec.ts @@ -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(); +}); diff --git a/e2e/customer.spec.ts b/e2e/customer.spec.ts new file mode 100644 index 0000000..ebcf993 --- /dev/null +++ b/e2e/customer.spec.ts @@ -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(); +}); diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts new file mode 100644 index 0000000..7e62942 --- /dev/null +++ b/e2e/fixtures/auth.ts @@ -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 { + 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(); +} diff --git a/e2e/fixtures/mailpit.ts b/e2e/fixtures/mailpit.ts new file mode 100644 index 0000000..813a131 --- /dev/null +++ b/e2e/fixtures/mailpit.ts @@ -0,0 +1,41 @@ +const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025"; + +export async function clearMailpit(): Promise { + 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 { + 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 { + 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`); +} diff --git a/e2e/therapist.spec.ts b/e2e/therapist.spec.ts new file mode 100644 index 0000000..7bf3a52 --- /dev/null +++ b/e2e/therapist.spec.ts @@ -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(); +}); diff --git a/package.json b/package.json index 7cea98b..06b9b4e 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "build": "next build", "start": "next start", "lint": "eslint", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", + "verify": "scripts/verify.sh", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", @@ -41,6 +44,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", "@types/node": "^22", "@types/nodemailer": "^8.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6b5b7ec --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Dedicated test port — keeps the e2e server isolated from any `pnpm dev` +// the user might have running, and pins it against the touchbase_test DB +// (loaded from .env.test in the webServer command below). +const PORT = Number(process.env.E2E_PORT ?? 3010); +const BASE_URL = process.env.E2E_BASE_URL ?? `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [["list"]], + timeout: 30_000, + expect: { timeout: 10_000 }, + use: { + baseURL: BASE_URL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "off", + }, + webServer: { + // dotenv-cli loads .env.test (DATABASE_URL=touchbase_test) for the child. + command: `pnpm exec dotenv -e .env.test -- next dev -p ${PORT}`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + stdout: "pipe", + stderr: "pipe", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f20be6c..9ac11aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,10 +31,10 @@ importers: version: 3.2.0(date-fns@4.1.0) next: specifier: 16.2.4 - version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-auth: specifier: 5.0.0-beta.31 - version: 5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.7)(react@19.2.4) + version: 5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.7)(react@19.2.4) nodemailer: specifier: ^8.0.7 version: 8.0.7 @@ -57,6 +57,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@tailwindcss/postcss': specifier: ^4 version: 4.2.4 @@ -682,6 +685,11 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1785,6 +1793,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2435,6 +2448,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3475,6 +3498,10 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@polka/url@1.0.0-next.29': {} '@prisma/adapter-pg@7.8.0': @@ -4733,6 +4760,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5160,15 +5190,15 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.7)(react@19.2.4): + next-auth@5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.7)(react@19.2.4): dependencies: '@auth/core': 0.41.2(nodemailer@8.0.7) - next: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 optionalDependencies: nodemailer: 8.0.7 - next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.4 '@swc/helpers': 0.5.15 @@ -5187,6 +5217,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.4 '@next/swc-win32-arm64-msvc': 16.2.4 '@next/swc-win32-x64-msvc': 16.2.4 + '@playwright/test': 1.59.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5345,6 +5376,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: diff --git a/ruvector.db b/ruvector.db new file mode 100644 index 0000000000000000000000000000000000000000..9e2e38d46fad560d6e14820a861afbfbb1c5a568 GIT binary patch literal 1589248 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 %s\033[0m\n" "$*"; } +fail() { printf "\033[31merror:\033[0m %s\n" "$*" >&2; exit 1; } + +E2E_PORT="${E2E_PORT:-3010}" +export E2E_PORT + +# --- Pre-flight --------------------------------------------------------------- +bold "Pre-flight" +curl -sf http://localhost:5432 -o /dev/null || true # informational +curl -sf "http://localhost:8025/api/v1/info" >/dev/null \ + || fail "Mailpit not reachable on http://localhost:8025. Run: docker compose up -d mailpit" + +if ! docker ps --format '{{.Names}}' | grep -q '^touchbase-postgres-1$'; then + fail "Postgres container 'touchbase-postgres-1' not running. Run: docker compose up -d postgres" +fi + +# Next 16 enforces one `next dev` per project. If one is running it'll block +# the e2e test server from starting on E2E_PORT. +if pgrep -f "next dev" >/dev/null 2>&1; then + fail "Another \`next dev\` is running (Next 16 enforces one per project). + Stop it before running verify: pkill -f 'next dev' + Then re-run: pnpm verify" +fi + +# --- Lint --------------------------------------------------------------------- +bold "Lint" +pnpm lint + +# --- Typecheck ---------------------------------------------------------------- +bold "Typecheck" +pnpm exec tsc --noEmit + +# --- Test DB: ensure schema is current --------------------------------------- +bold "Apply migrations to test DB" +pnpm exec dotenv -e .env.test -- prisma migrate deploy + +# --- Vitest ------------------------------------------------------------------- +# Vitest tests load .env.test via test/setup.ts and manage their own data; +# we don't need to reset before this step. +bold "Vitest" +pnpm test + +# --- Truncate + seed test DB before Playwright ------------------------------ +# Vitest may have left rows around. Playwright fixtures (admin/therapist/ +# customer accounts, services, rooms) need a known seeded state. +# Truncate via psql (faster than `migrate reset`, avoids Prisma's AI guard). +bold "Truncate + seed test DB" +docker exec -i touchbase-postgres-1 \ + psql -U touchbase -d touchbase_test -v ON_ERROR_STOP=1 -q \ + < scripts/db-test-truncate.sql +pnpm exec dotenv -e .env.test -- tsx prisma/seed.ts + +# --- Playwright e2e ----------------------------------------------------------- +# playwright.config.ts has a webServer block that boots `next dev` on +# E2E_PORT against .env.test, so we don't manage the server here. +bold "Playwright e2e (port $E2E_PORT)" +pnpm exec playwright test + +bold "All green" diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..295acdb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -28,7 +34,10 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + ".next/dev/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }