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 0000000..9e2e38d Binary files /dev/null and b/ruvector.db differ diff --git a/scripts/db-test-truncate.sql b/scripts/db-test-truncate.sql new file mode 100644 index 0000000..8751a16 --- /dev/null +++ b/scripts/db-test-truncate.sql @@ -0,0 +1,22 @@ +-- Truncate all Prisma-managed application tables in touchbase_test. +-- Used by `pnpm verify` to give Playwright a clean fixture-only DB state +-- without triggering Prisma's AI-invocation guard on `migrate reset`. +-- pg-boss tables (schema "pgboss") are intentionally left alone. +TRUNCATE TABLE + "AuditLog", + "Notification", + "Payment", + "Booking", + "AvailabilityOverride", + "WorkingHours", + "ServiceTherapist", + "Service", + "RoomBlock", + "RoomTag", + "Room", + "TherapistTag", + "Therapist", + "Customer", + "VerificationToken", + "User" +RESTART IDENTITY CASCADE; diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..5e19c1d --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Run the full verify suite: lint, typecheck, vitest, Playwright e2e. +# Uses the dedicated touchbase_test DB (reset + seeded) and a Next.js +# server on E2E_PORT (3010) so it never touches the dev DB or dev server. + +set -euo pipefail +cd "$(dirname "$0")/.." + +bold() { printf "\n\033[1m==> %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" + ] }