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();
|
||||||
|
});
|
||||||
@@ -10,8 +10,11 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"verify": "scripts/verify.sh",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:migrate:deploy": "prisma migrate deploy",
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
|||||||
35
playwright.config.ts
Normal file
35
playwright.config.ts
Normal file
@@ -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"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -31,10 +31,10 @@ importers:
|
|||||||
version: 3.2.0(date-fns@4.1.0)
|
version: 3.2.0(date-fns@4.1.0)
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.4
|
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:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.31
|
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:
|
nodemailer:
|
||||||
specifier: ^8.0.7
|
specifier: ^8.0.7
|
||||||
version: 8.0.7
|
version: 8.0.7
|
||||||
@@ -57,6 +57,9 @@ importers:
|
|||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.59.1
|
||||||
|
version: 1.59.1
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
@@ -682,6 +685,11 @@ packages:
|
|||||||
'@panva/hkdf@1.2.1':
|
'@panva/hkdf@1.2.1':
|
||||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
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':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
@@ -1785,6 +1793,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -2435,6 +2448,16 @@ packages:
|
|||||||
pkg-types@2.3.1:
|
pkg-types@2.3.1:
|
||||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
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:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3475,6 +3498,10 @@ snapshots:
|
|||||||
|
|
||||||
'@panva/hkdf@1.2.1': {}
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.59.1
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@prisma/adapter-pg@7.8.0':
|
'@prisma/adapter-pg@7.8.0':
|
||||||
@@ -4733,6 +4760,9 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5160,15 +5190,15 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
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:
|
dependencies:
|
||||||
'@auth/core': 0.41.2(nodemailer@8.0.7)
|
'@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
|
react: 19.2.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
nodemailer: 8.0.7
|
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:
|
dependencies:
|
||||||
'@next/env': 16.2.4
|
'@next/env': 16.2.4
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -5187,6 +5217,7 @@ snapshots:
|
|||||||
'@next/swc-linux-x64-musl': 16.2.4
|
'@next/swc-linux-x64-musl': 16.2.4
|
||||||
'@next/swc-win32-arm64-msvc': 16.2.4
|
'@next/swc-win32-arm64-msvc': 16.2.4
|
||||||
'@next/swc-win32-x64-msvc': 16.2.4
|
'@next/swc-win32-x64-msvc': 16.2.4
|
||||||
|
'@playwright/test': 1.59.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -5345,6 +5376,14 @@ snapshots:
|
|||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
pathe: 2.0.3
|
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: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
|
|||||||
BIN
ruvector.db
Normal file
BIN
ruvector.db
Normal file
Binary file not shown.
22
scripts/db-test-truncate.sql
Normal file
22
scripts/db-test-truncate.sql
Normal file
@@ -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;
|
||||||
67
scripts/verify.sh
Executable file
67
scripts/verify.sh
Executable file
@@ -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"
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -28,7 +34,10 @@
|
|||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts",
|
||||||
|
".next/dev/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user