diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88fd88e..9a83a33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,5 +35,5 @@ jobs: - name: Run tests run: npm run test -- --exclude tests/setup/base.test.ts env: - APP_BASE_URL: dummy_url_for_testing + APP_BASE_URL: http://dummy_url_for_testing APP_SECRET: dummy_secret_for_testing diff --git a/.gitignore b/.gitignore index a1c089d..5edce76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ coverage +data dist node_modules -src/database/data -src/mailpit/data .DS_Store .env *.http diff --git a/AGENTS.md b/AGENTS.md index b486e92..efca474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - **Backend**: Node.js + Express 5, TypeScript, Zod (validation), `node:sqlite` (sync API) - **Frontend**: React 19, React Router (with SSR/hydration), Vite, Pico CSS -- **Database**: SQLite — zero-config, synchronous, file at `src/database/data/database.sqlite` +- **Database**: SQLite — zero-config, synchronous, file at `data/sqlite/database.sqlite` - **Tooling**: Biome (lint + format), Vitest (tests), tsx (runtime), Docker (optional) --- @@ -20,14 +20,16 @@ . ├── server.ts # Single entry point — bridges Express + Vite ├── index.html # Vite root — contains +├── data/ +│ ├── mailpit/ # Mailpit persistence (Docker) +│ └── sqlite/ +│ └── database.sqlite # Generated locally — NOT committed to git ├── src/ │ ├── entry-client.tsx # Client-side hydration (hydrateRoot) │ ├── entry-server.tsx # SSR rendering (renderToPipeableStream) │ ├── database/ │ │ ├── schema.sql # SQLite schema — source of truth for DB structure -│ │ ├── seeder.sql # Test/seed data -│ │ └── data/ -│ │ └── database.sqlite # Generated locally — NOT committed to git +│ │ └── seeder.sql # Test/seed data │ ├── express/ │ │ ├── routes.ts # Registers all Express modules via importAndUse() │ │ ├── helpers/ # Infrastructure: cache, validation, converters @@ -45,7 +47,7 @@ │ └── types/ │ └── index.d.ts # Shared TypeScript types (Item, User, etc.) ├── tests/ -│ └── contracts.ts # API contract definitions — declarative source of truth +│ └── contracts # API contract definitions — declarative source of truth └── biome.json # Lint + format config ``` @@ -169,31 +171,50 @@ const browse: RequestHandler = (req, res) => { }; ``` -### Explicit runtime casting — never use `as Type` +### Zod Output Parsing — never use `as Type` -SQLite returns raw SQL primitives (`string | number | bigint | null`). Always reconstruct objects with explicit primitive converters: +SQLite returns raw SQL primitives (`string | number | bigint | null`). Always reconstruct objects by parsing them through a Zod schema bound to your TypeScript type (`z.ZodType`). + +> **⚠️ CRITICAL**: Do NOT confuse the **Output Schema** (in `*Repository.ts`) with the **Input Schema** (in `*Validator.ts`). The Repository output schema only casts raw primitives to match the TypeScript type. It does NOT enforce business constraints (like `.email()` or `.min()`). ```ts +// In your Repository file +import { z } from "zod"; + +const itemSchema: z.ZodType = z.object({ + id: z.number(), + title: z.string(), + user_id: z.number() +}); + // ✅ Correct -return { id: Number(id), title: String(title), user_id: Number(user_id) }; +return itemSchema.parse(row); // ❌ Wrong — hides runtime errors return row as Item; ``` -### Synchronous SQLite — no async/await in repositories +### Synchronous SQLite — no async/await for database calls -`node:sqlite` is synchronous by design. Repositories must not use `async`/`await`. Actions can remain `async` if they need to interact with other async concerns (e.g., `req.body`, external calls). +`node:sqlite` is synchronous by design. Repository methods must execute SQL queries synchronously and must not wrap database calls in `async`/`await`. + +However, a repository method *may* be marked `async` if it strictly requires interaction with genuinely asynchronous external resources (e.g., third-party APIs, asynchronous file system reads). Actions (in `*Actions.ts`) generally remain `async` to handle `req.body` and orchestrate these calls. ```ts -// ✅ Correct repository method +// ✅ Correct SQLite query find(byId: number): Item | null { const query = database.prepare("select id, title from item where id = ?"); const row = query.get(byId); // ... } -// ❌ Wrong +// ✅ Correct (Mixed resource query) +async fetchAndSave(id: number): Promise { + const data = await fetch(`https://api.example.com/data/${id}`); + database.prepare("insert into item (title) values (?)").run(data.title); +} + +// ❌ Wrong (Wrapping SQLite in async for no reason) async find(byId: number): Promise { ... } ``` @@ -262,7 +283,7 @@ StartER has no file upload handling. If adding one, store files outside the docu ### Environment variables -Never commit `.env`. Never commit `src/database/data/database.sqlite`. Both are in `.gitignore`. Generate `APP_SECRET` with `openssl rand -hex 32`. +Never commit `.env`. Never commit `data/sqlite/database.sqlite`. Both are in `.gitignore`. Generate `APP_SECRET` with `openssl rand -hex 32`. --- diff --git a/README.fr-FR.md b/README.fr-FR.md index 0697db0..a4223c9 100644 --- a/README.fr-FR.md +++ b/README.fr-FR.md @@ -22,14 +22,6 @@ -## 🧠 Starter, le framework idéal pour l'IA - -La plupart des frameworks sont trop complexes pour l'IA. Ils dissimulent la logique derrière des abstractions complexes et opaques, ce qui peut entraîner des dysfonctionnements et des erreurs de conception chez les agents. - -**Nous avons conçu StartER pour nous démarquer.** Il s'agit d'une plateforme "sans magie" conçue pour la **co-création humain-IA**. En conservant un code lisible et explicite, nous fournissons aux agents IA un modèle mental optimal. StartER devient ainsi le terrain de jeu idéal pour le prototypage et l'apprentissage rapides. - -![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png) - ## 📚 Exemple de structure de projet Express + React simple et lisible Ce projet présente une méthode simple et lisible pour structurer une application fullstack avec : @@ -38,7 +30,15 @@ Ce projet présente une méthode simple et lisible pour structurer une applicati * Frontend React * Contrats partagés pour l'API -Si vous recherchez un "starter Express + React" ou un "boilerplate Node React", ce dépôt est un exemple pratique. +Si vous recherchez un "starter Express + React" ou un "boilerplate Node React", ce dépôt est un template pratique. + +## 🧠 Starter, le framework idéal pour l'IA + +La plupart des frameworks sont trop complexes pour l'IA. Ils dissimulent la logique derrière des abstractions complexes et opaques, ce qui peut entraîner des dysfonctionnements et des erreurs de conception chez les agents. + +**Nous avons conçu StartER pour nous démarquer.** Il s'agit d'une plateforme "sans magie" conçue pour la **co-création humain-IA**. En conservant un code lisible et explicite, nous fournissons aux agents IA un modèle mental optimal. StartER devient ainsi le terrain de jeu idéal pour le prototypage et l'apprentissage rapides. + +![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png) ## ⚡ Démarrage Rapide @@ -70,7 +70,7 @@ Cela garantit la cohérence en clonant vos modèles de code *réels*. Votre agen ### 🧪 Vérification basée sur un contrat -Vous définissez le comportement de l'API dans `tests/contracts.ts` : une source de vérité centrale et déclarative. +Vous définissez le comportement de l'API dans le dossier `tests/contracts/` : une source de vérité centrale et déclarative. * **Pour vous :** une documentation claire et évolutive. @@ -82,7 +82,7 @@ Vous définissez le comportement de l'API dans `tests/contracts.ts` : une sourc * **SQLite synchrone :** accès direct aux données que l'IA peut lire et écrire sans confusion avec `async`/`await`. -* **Conversion explicite :** typage des données aux emplacements clés. Ceci évite les bugs silencieux souvent introduits par l'IA. +* **Validation de sortie avec Zod :** typage des données aux emplacements clés à l'aide de schémas Zod. Ceci évite les bugs silencieux souvent introduits par l'IA. * **Stack transparente :** Express 5 + React 19. Aucune boîte noire. Vous comprenez chaque ligne. diff --git a/README.md b/README.md index 23aec71..3f875e7 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,6 @@ -## 🧠 The AI-era starter - -Most frameworks are too complex for AI. They hide logic behind "magic" and deep abstractions. This causes AI agents to hallucinate and break things. - -**We built StartER to stand out.** It is a "Zero-Magic" foundation designed for **Human-AI co-creation**. By keeping the code readable and explicit, we provide AI agents with a perfect mental model. This makes it the ultimate playground for rapid prototyping and learning. - -![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png) - ## 📚 Simple and readable Express + React project structure example This project shows a simple and readable way to structure a fullstack app with: @@ -37,7 +29,15 @@ This project shows a simple and readable way to structure a fullstack app with: - React frontend - shared contracts for API -If you are looking for a "Express + React starter" or "Node React boilerplate", this repository is a practical example. +If you are looking for a "Express + React starter" or "Node React boilerplate", this repository is a practical template. + +## 🧠 The AI-era starter + +Most frameworks are too complex for AI. They hide logic behind "magic" and deep abstractions. This causes AI agents to hallucinate and break things. + +**We built StartER to stand out.** It is a "Zero-Magic" foundation designed for **Human-AI co-creation**. By keeping the code readable and explicit, we provide AI agents with a perfect mental model. This makes it the ultimate playground for rapid prototyping and learning. + +![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png) ## ⚡ Quick start @@ -66,14 +66,14 @@ npm run make:clone -- src/express/modules/item src/express/modules/task item tas This enforces consistency by cloning your *actual* code patterns. This keeps your AI agent focused and accurate. ### 🧪 Contract-driven verification -You define API behavior in `tests/contracts.ts`: a central, declarative source of truth. +You define API behavior in the `tests/contracts/` directory: a central, declarative source of truth. * **For you:** clear, living documentation. * **For AI:** a strict "contract" it must follow when generating endpoints. * **For the app:** instant verification that the AI didn't miss a scenario. ### 🔍 Zero-magic simplicity * **Sync SQLite:** direct data access that AI can read and write without `async`/`await` confusion. -* **Explicit casting:** we verify data at the edge. This prevents the silent bugs AI often introduces. +* **Zod Output Parsing:** we verify data at the edge using Zod schemas. This prevents the silent bugs AI often introduces. * **Transparent stack:** Express 5 + React 19. No black boxes. You understand every line. ## 💻 Tech stack diff --git a/compose.yaml b/compose.yaml index bc47963..bce5604 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,7 +22,7 @@ services: restart: unless-stopped env_file: ./.env volumes: - - ./src/mailpit/data:/data + - ./data/mailpit:/data ports: - ${MP_UI_PORT-8025}:8025 - ${MP_SMTP_PORT-1025}:1025 diff --git a/package.json b/package.json index 72c7d1b..91f368f 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,13 @@ "build": "npm run build:client && npm run build:server", "build:client": "vite build --outDir dist/client", "build:server": "vite build --outDir dist/server --ssr src/entry-server", - "database:schema:load": "tsx --env-file=.env bin/database-sync schema", - "database:seeder:load": "tsx --env-file=.env bin/database-sync seeder", - "database:sync": "tsx --env-file=.env bin/database-sync both", + "database:schema:load": "tsx --env-file=.env scripts/database-sync schema", + "database:seeder:load": "tsx --env-file=.env scripts/database-sync seeder", + "database:sync": "tsx --env-file=.env scripts/database-sync both", "dev": "tsx watch --include .env --env-file=.env server", "install:check": "vitest run tests/install", - "make:clone": "tsx --env-file=.env bin/make-clone", - "make:purge": "tsx --env-file=.env bin/make-purge", + "make:clone": "tsx --env-file=.env scripts/make-clone", + "make:purge": "tsx --env-file=.env scripts/make-purge", "start": "tsx --env-file=.env server", "test": "vitest run --coverage --exclude tests/install", "types:check": "tsc --noEmit" diff --git a/bin/database-sync.ts b/scripts/database-sync.ts similarity index 100% rename from bin/database-sync.ts rename to scripts/database-sync.ts diff --git a/bin/make-clone.ts b/scripts/make-clone.ts similarity index 100% rename from bin/make-clone.ts rename to scripts/make-clone.ts diff --git a/bin/make-purge.ts b/scripts/make-purge.ts similarity index 97% rename from bin/make-purge.ts rename to scripts/make-purge.ts index 85083d2..458d028 100644 --- a/bin/make-purge.ts +++ b/scripts/make-purge.ts @@ -133,7 +133,7 @@ async function purgeAuth(rootDir: string) { await updateFile(rootDir, "src/react/routes.tsx", (content) => content // Remove auth-related imports - .replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "") + .replace(`import AccountPage from "./components/auth/AccountPage";\n`, "") .replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "") .replace( `import { AuthProvider } from "./components/auth/AuthContext";\n`, @@ -151,9 +151,9 @@ async function purgeAuth(rootDir: string) { ) // Remove the loader .replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "") - // Remove logout and verify routes + // Remove account and verify routes .replace( - / {6}\{\n {8}path: "logout",\n {8}element: ,\n {6}\},\n/m, + / {6}\{\n {8}path: "account",\n {8}element: ,\n {6}\},\n/m, "", ) .replace( @@ -187,7 +187,7 @@ async function purgeAuth(rootDir: string) { content .replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "") .replace(` const { check } = useAuth();\n`, "") - // After purgeItems, only the logout link remains in the auth block. + // After purgeItems, only the account link remains in the auth block. // Remove the whole conditional block. .replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, ""), ); diff --git a/server.ts b/server.ts index 94d2f91..c24a063 100644 --- a/server.ts +++ b/server.ts @@ -22,13 +22,24 @@ * - https://vitejs.dev/guide/ssr */ -import { AsyncLocalStorage } from "node:async_hooks"; -import fs from "node:fs"; -import http from "node:http"; -import express, { type ErrorRequestHandler, type Express } from "express"; -import { rateLimit } from "express-rate-limit"; -import helmet from "helmet"; -import { createServer as createViteServer } from "vite"; +/* ************************************************************************ */ +/* Startup */ +/* ************************************************************************ */ + +const port = Number(process.env.APP_PORT ?? 5173); + +const indexHtml = readIndexHtml(); + +// Server creation is async because it may initialize Vite in dev mode +createServerWith("./src/express/routes").then((server) => { + server.listen(port, () => { + console.info(`Listening on http://localhost:${port}`); + }); +}); + +/* ************************************************************************ */ +/* Server creation */ +/* ************************************************************************ */ /** * Patch globalThis.fetch to support relative URLs during SSR. @@ -41,6 +52,8 @@ import { createServer as createViteServer } from "vite"; * - Create a storage that holds the base URL for the current request * - Patch fetch to resolve relative URLs against this base URL */ +import { AsyncLocalStorage } from "node:async_hooks"; + const fetchBaseStorage = new AsyncLocalStorage<{ base: string; cookie?: string; @@ -73,28 +86,17 @@ globalThis.fetch = (resource, init) => { return nodeFetch(url, init); }; -/* ************************************************************************ */ -/* Startup */ -/* ************************************************************************ */ +/** + * Express / Vite integration + */ +import http from "node:http"; +import express, { type ErrorRequestHandler } from "express"; +import { rateLimit } from "express-rate-limit"; +import helmet from "helmet"; const isProduction = process.env.NODE_ENV === "production"; -const port = +(process.env.APP_PORT ?? 5173); - -const indexHtml = readIndexHtml(); - -// Server creation is async because it may initialize Vite in dev mode -createServer().then((server) => { - server.listen(port, () => { - console.info(`Listening on http://localhost:${port}`); - }); -}); - -/* ************************************************************************ */ -/* Server creation */ -/* ************************************************************************ */ - -export async function createServer() { +export async function createServerWith(routesPath: string) { const app = express(); const httpServer = http.createServer(app); @@ -138,7 +140,7 @@ export async function createServer() { // All API routes are mounted here. // They are isolated, stateless, and independently testable. - app.use((await import("./src/express/routes")).default); + app.use((await import(routesPath)).default); /* ********************************************************************** */ /* Frontend / SSR configuration */ @@ -252,6 +254,8 @@ export async function createServer() { * - Development: unbuilt index.html * - Production: generated dist/client/index.html */ +import fs from "node:fs"; + function readIndexHtml() { return fs.readFileSync( isProduction ? "dist/client/index.html" : "index.html", @@ -270,6 +274,9 @@ function readIndexHtml() { * - Create a Vite dev server in middleware mode * - Let Express control routing */ +import type { Express } from "express"; +import { createServer as createViteServer } from "vite"; + async function configure(app: Express, httpServer: http.Server) { if (isProduction) { const compression = (await import("compression")).default; diff --git a/src/database/index.ts b/src/database/index.ts index bbf018b..4165380 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -18,7 +18,10 @@ import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import fs from "fs-extra"; -const dbPath = path.join(import.meta.dirname, "data/database.sqlite"); +const dbPath = path.join( + import.meta.dirname, + "../../data/sqlite/database.sqlite", +); // Ensure the parent directory exists await fs.ensureDir(path.dirname(dbPath)); diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 4e45150..a42a0ca 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -104,17 +104,19 @@ export const render = async (template: string, req: Request, res: Response) => { */ const leaf = context.matches[context.matches.length - 1]; - const actionHeaders = context.actionHeaders[leaf.route.id]; - if (actionHeaders) { - for (const [key, value] of actionHeaders.entries()) { - res.set(key, value); + if (leaf) { + const actionHeaders = context.actionHeaders[leaf.route.id]; + if (actionHeaders) { + for (const [key, value] of actionHeaders.entries()) { + res.set(key, value); + } } - } - const loaderHeaders = context.loaderHeaders[leaf.route.id]; - if (loaderHeaders) { - for (const [key, value] of loaderHeaders.entries()) { - res.set(key, value); + const loaderHeaders = context.loaderHeaders[leaf.route.id]; + if (loaderHeaders) { + for (const [key, value] of loaderHeaders.entries()) { + res.set(key, value); + } } } diff --git a/src/errors/HttpError.ts b/src/errors/HttpError.ts deleted file mode 100644 index 880dd3e..0000000 --- a/src/errors/HttpError.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class HttpError extends Error { - public status: number; - - constructor(status: number, message?: string) { - super(message); - this.status = status; - } -} - -export class NotFoundError extends HttpError { - constructor(message = "Not Found") { - super(404, message); - } -} diff --git a/src/express/modules/auth/authActions.ts b/src/express/modules/auth/authActions.ts index 8a4a074..c87d160 100644 --- a/src/express/modules/auth/authActions.ts +++ b/src/express/modules/auth/authActions.ts @@ -13,13 +13,14 @@ Security model: - Stateless authentication via JWT stored in HttpOnly cookies - - Short-lived access tokens + - Medium-lived tokens (7 days) */ import crypto from "node:crypto"; import type { CookieOptions, RequestHandler } from "express"; import jwt, { type JwtPayload } from "jsonwebtoken"; import nodemailer from "nodemailer"; +import { z } from "zod"; import userRepository from "../user/userRepository"; import authRepository from "./authRepository"; @@ -32,23 +33,21 @@ import authRepository from "./authRepository"; Environment variables. Must be defined at startup; failing fast is intentional. */ -const appBaseUrl = process.env.APP_BASE_URL; -const appSecret = process.env.APP_SECRET; -const smtpUrl = process.env.SMTP_URL; - -if (appBaseUrl == null) { - throw new Error("process.env.APP_BASE_URL is not defined"); -} - -if (appSecret == null) { - throw new Error("process.env.APP_SECRET is not defined"); -} - -const isProduction = process.env.NODE_ENV === "production"; - -if (isProduction && smtpUrl == null) { - throw new Error("SMTP_URL must be defined in production environment"); -} +const envSchema = z.object({ + APP_BASE_URL: z.url(), + APP_SECRET: z.string(), + SMTP_URL: z + .url() + .optional() + .refine( + (smtpUrl) => smtpUrl != null || process.env.NODE_ENV !== "production", + { + message: "SMTP_URL must be defined in production environment", + }, + ), +}); + +const env = envSchema.parse(process.env); /* Extend Express Request to carry authenticated user data. @@ -80,7 +79,7 @@ const cookieOptions: CookieOptions = { httpOnly: true, secure: true, sameSite: "strict", - maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }; /* @@ -104,11 +103,13 @@ class Auth { } } -const auth = new Auth(appSecret); +const auth = new Auth(env.APP_SECRET); -const transporter = smtpUrl ? nodemailer.createTransport(smtpUrl) : null; +const transporter = env.SMTP_URL + ? nodemailer.createTransport(env.SMTP_URL) + : null; -const trustedBaseUrl = appBaseUrl.replace(/\/+$/, ""); +const trustedBaseUrl = env.APP_BASE_URL.replace(/\/+$/, ""); /* ************************************************************************ */ /* Actions */ @@ -223,18 +224,6 @@ const destroyAccessToken: RequestHandler = (_req, res) => { res.sendStatus(204); }; -/* ************************************************************************ */ - -/* - Return the currently authenticated user. - - Preconditions: - - verifyAccessToken has run successfully -*/ -const readMe: RequestHandler = (req, res) => { - res.json(req.me); -}; - /* ************************************************************************ */ /* Middleware */ /* ************************************************************************ */ @@ -264,6 +253,10 @@ const verifyAccessToken: RequestHandler = (req, res, next) => { throw new Error("User not found"); } + // Refresh cookie (extends expiration) + const freshToken = auth.signSession({ sub: me.id.toString() }); + res.cookie("__Host-auth", freshToken, cookieOptions); + req.me = me; next(); @@ -280,6 +273,5 @@ export default { sendMagicLink, verifyMagicLink, destroyAccessToken, - readMe, verifyAccessToken, }; diff --git a/src/express/modules/auth/authRepository.ts b/src/express/modules/auth/authRepository.ts index 1b91052..1f72c68 100644 --- a/src/express/modules/auth/authRepository.ts +++ b/src/express/modules/auth/authRepository.ts @@ -3,8 +3,16 @@ Centralize all persistence logic related to Authentication tokens. */ +import { z } from "zod"; import database from "../../../database"; +const magicLinkTokenSchema: z.ZodType = z.object({ + user_id: z.number(), + token_hash: z.string(), + expires_at: z.coerce.date(), + consumed_at: z.coerce.date().nullable(), +}); + class AuthRepository { insertOrReplaceToken( userId: RowId, @@ -25,16 +33,7 @@ class AuthRepository { ); const row = query.get(tokenHash); - if (row == null) return null; - - const { user_id, token_hash, expires_at, consumed_at } = row; - - return { - user_id: Number(user_id), - token_hash: String(token_hash), - expires_at: new Date(String(expires_at)), - consumed_at: consumed_at ? new Date(String(consumed_at)) : null, - }; + return row ? magicLinkTokenSchema.parse(row) : null; } markAsConsumed(userId: RowId): boolean { diff --git a/src/express/modules/auth/authRoutes.ts b/src/express/modules/auth/authRoutes.ts index 8ff17b7..5c4c414 100644 --- a/src/express/modules/auth/authRoutes.ts +++ b/src/express/modules/auth/authRoutes.ts @@ -42,12 +42,6 @@ router.post("/api/auth/magic-link", authActions.sendMagicLink); router.post("/api/auth/verify", authActions.verifyMagicLink); router.post("/api/auth/logout", authActions.destroyAccessToken); -/* ************************************************************************ */ -/* Authenticated routes */ -/* ************************************************************************ */ - -router.get("/api/me", authActions.verifyAccessToken, authActions.readMe); - /* ************************************************************************ */ /* Export */ /* ************************************************************************ */ diff --git a/src/express/modules/item/itemRepository.ts b/src/express/modules/item/itemRepository.ts index 53340d6..80ea9e6 100644 --- a/src/express/modules/item/itemRepository.ts +++ b/src/express/modules/item/itemRepository.ts @@ -18,8 +18,20 @@ - Soft delete is the default find behavior */ +import { z } from "zod"; + import database from "../../../database"; +/* ************************************************************************ */ +/* Schemas */ +/* ************************************************************************ */ + +const itemSchema: z.ZodType = z.object({ + id: z.number(), + title: z.string(), + user_id: z.number(), +}); + /* ************************************************************************ */ /* Repository */ /* ************************************************************************ */ @@ -69,13 +81,7 @@ class ItemRepository { ) .get(byId); - if (row == null) { - return null; - } - - const { id, title, user_id } = row; - - return { id: Number(id), title: String(title), user_id: Number(user_id) }; + return row ? itemSchema.parse(row) : null; } /* @@ -91,11 +97,7 @@ class ItemRepository { ) .all(limit, offset); - return rows.map(({ id, title, user_id }) => ({ - id: Number(id), - title: String(title), - user_id: Number(user_id), - })); + return rows.map((row) => itemSchema.parse(row)); } /* ********************************************************************** */ diff --git a/src/express/modules/user/userActions.ts b/src/express/modules/user/userActions.ts index 8ecd64d..c594f12 100644 --- a/src/express/modules/user/userActions.ts +++ b/src/express/modules/user/userActions.ts @@ -28,52 +28,29 @@ import userRepository from "./userRepository"; /* ************************************************************************ */ /* - Browse all users. + Return the currently authenticated user. Preconditions: - - None (public endpoint) - - Response: - - 200 with an array of users -*/ -const browse: RequestHandler = (req, res) => { - const offset = Number(req.query.start ?? "0"); - - const users = userRepository.findAll(10, offset); - - res.json(users); -}; - -/* ************************************************************************ */ - -/* - Read a single user. - - Preconditions: - - `req.user` has been injected by the param converter - - Response: - - 200 with the user payload + - verifyAccessToken has run successfully */ -const read: RequestHandler = (req, res) => { - res.json(req.user); +const readMe: RequestHandler = (req, res) => { + res.json(req.me); }; /* ************************************************************************ */ /* - Edit an existing user. + Edit the currently authenticated user. Preconditions: - User is authenticated - - User is authorized to access this user - req.body has been validated and sanitized Response: - 204 No Content on success */ -const edit: RequestHandler = (req, res) => { - userRepository.update(req.user.id, req.body); +const editMe: RequestHandler = (req, res) => { + userRepository.update(req.me.id, req.body); res.sendStatus(204); }; @@ -81,17 +58,16 @@ const edit: RequestHandler = (req, res) => { /* ************************************************************************ */ /* - Soft-delete a user. + Soft-delete the currently authenticated user. Preconditions: - User is authenticated - - User is authorized to access this user Response: - 204 No Content */ -const destroy: RequestHandler = (req, res) => { - userRepository.softDelete(req.user.id); +const destroyMe: RequestHandler = (req, res) => { + userRepository.softDelete(req.me.id); res.sendStatus(204); }; @@ -101,8 +77,7 @@ const destroy: RequestHandler = (req, res) => { /* ************************************************************************ */ export default { - browse, - read, - edit, - destroy, + readMe, + editMe, + destroyMe, }; diff --git a/src/express/modules/user/userRepository.ts b/src/express/modules/user/userRepository.ts index afe53e6..2d9f9f8 100644 --- a/src/express/modules/user/userRepository.ts +++ b/src/express/modules/user/userRepository.ts @@ -18,8 +18,20 @@ - Soft delete is the default find behavior */ +import z from "zod"; + import database from "../../../database"; +/* ************************************************************************ */ +/* Schemas */ +/* ************************************************************************ */ + +const userSchema: z.ZodType = z.object({ + id: z.number(), + email: z.string(), + name: z.string(), +}); + /* ************************************************************************ */ /* Repository */ /* ************************************************************************ */ @@ -69,32 +81,7 @@ class UserRepository { ); const row = query.get(byId); - if (row == null) { - return null; - } - - const { id, email, name } = row; - - return { id: Number(id), email: String(email), name: String(name) }; - } - - /* - Find all non-deleted users. - - Notes: - - Meant to be composed or extended if needed - */ - findAll(limit: number, offset: number): User[] { - const query = database.prepare( - "select id, email, name from user where deleted_at is null limit ? offset ?", - ); - const rows = query.all(limit, offset); - - return rows.map(({ id, email, name }) => ({ - id: Number(id), - email: String(email), - name: String(name), - })); + return row ? userSchema.parse(row) : null; } /* @@ -114,13 +101,7 @@ class UserRepository { ); const row = query.get(byEmail); - if (row == null) { - return null; - } - - const { id, email, name } = row; - - return { id: Number(id), email: String(email), name: String(name) }; + return row ? userSchema.parse(row) : null; } /* @@ -133,20 +114,18 @@ class UserRepository { Why null instead of throwing: - Allows upper layers to decide HTTP semantics (404, 204, etc.) */ - findOrCreateByEmail(email: string, name?: string): User { + findOrCreateByEmail(email: string): User { const user = this.findByEmail(email); if (user) return user; + const name = email.split("@")[0]; + const id = this.create({ email, - name: name ?? email.split("@")[0], + name, }); - return { - id: Number(id), - email: String(email), - name: String(name ?? email.split("@")[0]), - }; + return { id, email, name }; } /* ********************************************************************** */ @@ -191,31 +170,6 @@ class UserRepository { return result.changes > 0; } - - /* - Restore a soft-deleted user. - */ - softUndelete(id: RowId): boolean { - const query = database.prepare( - "update user set deleted_at = null where id = ?", - ); - const result = query.run(id); - - return result.changes > 0; - } - - /* - Hard delete a user. - - Warning: - - This permanently removes the row - */ - hardDelete(id: RowId): boolean { - const query = database.prepare("delete from user where id = ?"); - const result = query.run(id); - - return result.changes > 0; - } } /* ************************************************************************ */ diff --git a/src/express/modules/user/userRoutes.ts b/src/express/modules/user/userRoutes.ts index 05d016a..abcf973 100644 --- a/src/express/modules/user/userRoutes.ts +++ b/src/express/modules/user/userRoutes.ts @@ -3,19 +3,14 @@ Routes related to "users" resources. This file defines: - - Public read endpoints - - Authenticated write endpoints - - Ownership-based authorization rules + - Authenticated endpoints Guiding principles: - - Read access is public - - Write access is authenticated - - Mutations are restricted to resource owners + - Users can only access their own data Related docs: - https://restfulapi.net/resource-naming/ - https://expressjs.com/en/guide/routing.html - - https://expressjs.com/en/5x/api.html#router.param */ /* ************************************************************************ */ @@ -44,14 +39,6 @@ import authActions from "../auth/authActions"; */ import userActions from "./userActions"; -/* - userParamConverter: - - Centralizes user lookup - - Attaches `req.user` - - Fails fast if user does not exist -*/ -import userParamConverter from "./userParamConverter"; - /* userValidator: - Validates request payloads @@ -68,86 +55,23 @@ import userValidator from "./userValidator"; - Avoid duplication - Make refactors trivial */ -const BASE_PATH = "/api/users"; -const USER_PATH = "/api/users/:userId"; - -/* ************************************************************************ */ -/* Param converter */ -/* ************************************************************************ */ - -/* - Automatically resolves :userId parameters. - - After this middleware: - - req.user is guaranteed to exist - - Downstream handlers can assume a valid user -*/ -router.param("userId", userParamConverter.convert); - -/* ************************************************************************ */ -/* Authorization rules */ -/* ************************************************************************ */ - -import type { RequestHandler } from "express"; - -/* - Ownership check. - - Authorization logic is kept: - - Explicit - - Local to the resource - - Easy to audit - - Assumptions: - - req.params.userId is the owner - - req.me.id is the authenticated user id -*/ -const checkAccess: RequestHandler = (req, res, next) => { - if (req.user.id === req.me.id) { - next(); - } else { - res.sendStatus(403); - } -}; - -/* ************************************************************************ */ -/* Public routes */ -/* ************************************************************************ */ - -/* - Public read-only endpoints. - No authentication required. -*/ -router.get(BASE_PATH, userActions.browse); -router.get(USER_PATH, userActions.read); - -/* ************************************************************************ */ -/* Authentication wall */ -/* ************************************************************************ */ - -/* - Everything below this line requires authentication. - - This pattern: - - Makes the security boundary visually obvious - - Avoids repeating auth middleware on every route -*/ -router.use(BASE_PATH, authActions.verifyAccessToken); +const ME_PATH = "/api/users/me"; /* ************************************************************************ */ /* Authenticated routes */ /* ************************************************************************ */ /* - User-specific mutations. - - Authentication already enforced - - Ownership enforced via checkAccess + User-specific routes. + - Authentication is enforced + - Users can only access their own data */ router - .route(USER_PATH) - .all(checkAccess) - .put(userValidator.validate, userActions.edit) - .delete(userActions.destroy); + .route(ME_PATH) + .all(authActions.verifyAccessToken) + .get(userActions.readMe) + .put(userValidator.validate, userActions.editMe) + .delete(userActions.destroyMe); /* ************************************************************************ */ /* Export */ diff --git a/src/express/routes.ts b/src/express/routes.ts index 57f28f6..3af4471 100644 --- a/src/express/routes.ts +++ b/src/express/routes.ts @@ -78,6 +78,10 @@ router.post("/api/health", (req, res) => { res.json(req.body); }); +router.delete("/api/health", (_req, res) => { + res.sendStatus(204); +}); + /* ************************************************************************ */ /* Module composition */ /* ************************************************************************ */ diff --git a/src/react/components/ErrorPage.tsx b/src/react/components/ErrorPage.tsx index 6420875..4d1c636 100644 --- a/src/react/components/ErrorPage.tsx +++ b/src/react/components/ErrorPage.tsx @@ -8,17 +8,12 @@ */ import { isRouteErrorResponse, Link, useRouteError } from "react-router"; -import { HttpError } from "../../errors/HttpError"; const getTitleAndMessage = (error: unknown): [string, string] => { if (isRouteErrorResponse(error)) { return [String(error.status), String(error.data)]; } - if (error instanceof HttpError) { - return [String(error.status), error.message]; - } - if (error instanceof Error) { return ["Oops!", error.message]; } diff --git a/src/react/components/NavBar.tsx b/src/react/components/NavBar.tsx index 32f75e4..5ee00be 100644 --- a/src/react/components/NavBar.tsx +++ b/src/react/components/NavBar.tsx @@ -43,7 +43,7 @@ function NavBar() { {check() && ( <> {link("/items", "Items")} - {link("/logout", "Logout")} + {link("/account", "Account")} )} diff --git a/src/react/components/auth/AccountDeleteForm.tsx b/src/react/components/auth/AccountDeleteForm.tsx new file mode 100644 index 0000000..0749f08 --- /dev/null +++ b/src/react/components/auth/AccountDeleteForm.tsx @@ -0,0 +1,42 @@ +/* + Purpose: + Minimal form component responsible for triggering account deletion. + + Design notes: + - Use a native
to keep semantics explicit + - Delegates all side effects to the useAuth hook + + Related docs: + - https://react.dev/reference/react-dom/components/form +*/ + +import { useAuth } from "./AuthContext"; + +function AccountDeleteForm() { + const { deleteMe } = useAuth(); + + return ( + { + if (confirm("Are you sure you want to delete your account?")) { + deleteMe(); + } + }} + > +
+

Account deletion

+

+ You can ask an admin to restore it later. You can ask for a permanent + deletion if you wish. In any case, your account will be deleted and + you will no longer be able to log in. +

+
+ + +
+ ); +} + +export default AccountDeleteForm; diff --git a/src/react/components/auth/AccountDetailsForm.tsx b/src/react/components/auth/AccountDetailsForm.tsx new file mode 100644 index 0000000..2fd8ad6 --- /dev/null +++ b/src/react/components/auth/AccountDetailsForm.tsx @@ -0,0 +1,66 @@ +/* + Purpose: + Minimal form component responsible for updating user details. + + Design notes: + - Use a native
to keep semantics explicit + - Delegates all side effects to the useAuth hook + + Related docs: + - https://react.dev/reference/react-dom/components/form +*/ + +import z from "zod"; +import { useAuth } from "./AuthContext"; + +const schema = z.object({ + email: z.email("Email invalide"), + name: z.string().min(1, "Nom requis"), +}); + +function AccountDetailsForm() { + const { me, updateMe } = useAuth(); + + return ( + { + const email = formData.get("email")?.toString(); + const name = formData.get("name")?.toString(); + + const parsed = schema.safeParse({ email, name }); + if (!parsed.success) { + alert(z.prettifyError(parsed.error)); + return; + } + + updateMe(parsed.data); + }} + > +
+ + +
+
+ + +
+ + +
+ ); +} + +export default AccountDetailsForm; diff --git a/src/react/components/auth/AccountPage.tsx b/src/react/components/auth/AccountPage.tsx new file mode 100644 index 0000000..1803085 --- /dev/null +++ b/src/react/components/auth/AccountPage.tsx @@ -0,0 +1,35 @@ +/* + Purpose: + Page component responsible for the account settings. + + Design notes: + - Uses AccountDetailsForm to update user details + - Uses LogoutForm to log out + - Uses AccountDeleteForm to delete the account + + Related docs: + - https://react.dev/reference/react-dom/components/form +*/ + +import AccountDeleteForm from "./AccountDeleteForm"; +import AccountDetailsForm from "./AccountDetailsForm"; +import LogoutForm from "./LogoutForm"; + +function AccountPage() { + return ( + <> +
+

Account

+

Manage your personal information here.

+
+ + + + + + + + ); +} + +export default AccountPage; diff --git a/src/react/components/auth/AuthContext.tsx b/src/react/components/auth/AuthContext.tsx index 009d7ef..047fbe0 100644 --- a/src/react/components/auth/AuthContext.tsx +++ b/src/react/components/auth/AuthContext.tsx @@ -5,7 +5,7 @@ This context: - Stores the currently authenticated user (or null) - Exposes high-level auth actions (sendMagicLink, verifyMagicLink, logout) - - Performs an initial session check on mount (/api/me) + - Performs an initial session check on mount (/api/users/me) Usage: - Wrap the app with @@ -19,7 +19,7 @@ import { useContext, useState, } from "react"; -import { HttpError } from "../../../errors/HttpError"; +import { cache } from "../../helpers/cache"; import { apiMutate } from "../../helpers/mutate"; /* ************************************************************************ */ @@ -32,6 +32,10 @@ type AuthContextType = { sendMagicLink: (email: string) => Promise; verifyMagicLink: (token: string) => Promise; logout: () => Promise; + updateMe: ( + newMe: Omit, + ) => Promise; + deleteMe: () => Promise; }; /* ************************************************************************ */ @@ -64,26 +68,29 @@ export function AuthProvider({ const verifyMagicLink = useCallback(async (token: string) => { const response = await apiMutate("/api/auth/verify", "post", { token }); - - if (response.ok) { - const data: User = await response.json(); - setUser(data); - } else { - throw new HttpError( - response.status, - "Verification of the magic link failed", - ); - } + const data: User = await response.json(); + setUser(data); }, []); const logout = useCallback(async () => { - const response = await apiMutate("/api/auth/logout", "post"); + await apiMutate("/api/auth/logout", "post"); + + setUser(null); + }, []); + + const updateMe = useCallback( + async (newMe: Omit) => { + await apiMutate("/api/users/me", "put", newMe); + + setUser(await cache("/api/users/me")); + }, + [], + ); + + const deleteMe = useCallback(async () => { + await apiMutate("/api/users/me", "delete"); - if (response.ok) { - setUser(null); - } else { - throw new HttpError(response.status, "Logout failed"); - } + setUser(null); }, []); /* ********************************************************************** */ @@ -98,6 +105,8 @@ export function AuthProvider({ sendMagicLink, verifyMagicLink, logout, + updateMe, + deleteMe, }} > {children} diff --git a/src/react/components/auth/LogoutForm.tsx b/src/react/components/auth/LogoutForm.tsx index 70e3df6..3e7ac22 100644 --- a/src/react/components/auth/LogoutForm.tsx +++ b/src/react/components/auth/LogoutForm.tsx @@ -17,7 +17,14 @@ function LogoutForm() { return (
- +
+

Log out

+

You will be logged out of your account.

+
+ +
); } diff --git a/src/react/components/item/ItemCreate.tsx b/src/react/components/item/ItemCreate.tsx index 51f3f70..f747a15 100644 --- a/src/react/components/item/ItemCreate.tsx +++ b/src/react/components/item/ItemCreate.tsx @@ -28,11 +28,9 @@ function ItemCreate() { "/api/items", ]); - if (response.ok) { - const { insertId } = await response.json(); + const { insertId } = await response.json(); - navigate(`/items/${insertId}`); - } + navigate(`/items/${insertId}`); }, [mutate, navigate], ); diff --git a/src/react/components/item/ItemDeleteForm.tsx b/src/react/components/item/ItemDeleteForm.tsx index 70cb462..63aac74 100644 --- a/src/react/components/item/ItemDeleteForm.tsx +++ b/src/react/components/item/ItemDeleteForm.tsx @@ -20,14 +20,12 @@ function ItemDeleteForm() { const { id } = useParams(); const deleteItem = useCallback(async () => { - const response = await mutate(`/api/items/${id}`, "delete", null, [ + await mutate(`/api/items/${id}`, "delete", null, [ "/api/items", `/api/items/${id}`, ]); - if (response.ok) { - navigate("/items"); - } + navigate("/items"); }, [id, mutate, navigate]); return ( diff --git a/src/react/components/item/ItemEdit.tsx b/src/react/components/item/ItemEdit.tsx index e4049ae..63c2145 100644 --- a/src/react/components/item/ItemEdit.tsx +++ b/src/react/components/item/ItemEdit.tsx @@ -15,7 +15,6 @@ import { use, useCallback } from "react"; import { useNavigate, useParams } from "react-router"; -import { NotFoundError } from "../../../errors/HttpError"; import { cache } from "../../helpers/cache"; import { useMutate } from "../../helpers/mutate"; import ItemForm from "./ItemForm"; @@ -27,33 +26,17 @@ function ItemEdit() { const editItem = useCallback( async (partialItem: Omit) => { - const response = await mutate(`/api/items/${id}`, "put", partialItem, [ + await mutate(`/api/items/${id}`, "put", partialItem, [ "/api/items", `/api/items/${id}`, ]); - if (response.ok) { - navigate(`/items/${id}`); - } + navigate(`/items/${id}`); }, [id, mutate, navigate], ); - const item = use(cache(`/api/items/${id}`)); - - /* - Safety guard: - - If the item is missing at this stage, it means: - - The route does not exist - - OR the user does not have access - - OR the data is stale - - Throwing allows the router error boundary to handle the 404. - */ - if (item == null) { - throw new NotFoundError(); - } + const item = use(cache(`/api/items/${id}`)); return ( /* diff --git a/src/react/components/item/ItemList.tsx b/src/react/components/item/ItemList.tsx index 32140c4..fe42a67 100644 --- a/src/react/components/item/ItemList.tsx +++ b/src/react/components/item/ItemList.tsx @@ -32,7 +32,7 @@ function ItemList() { - Suspends while loading (via `use`) - Invalidated after mutations */ - const items = use(cache("/api/items")); + const items = use(cache("/api/items")); return ( <> diff --git a/src/react/components/item/ItemShow.tsx b/src/react/components/item/ItemShow.tsx index c4fc57f..3232452 100644 --- a/src/react/components/item/ItemShow.tsx +++ b/src/react/components/item/ItemShow.tsx @@ -9,7 +9,6 @@ import { use } from "react"; import { Link, useParams } from "react-router"; -import { NotFoundError } from "../../../errors/HttpError"; import { cache } from "../../helpers/cache"; import { useAuth } from "../auth/AuthContext"; import ItemDeleteForm from "./ItemDeleteForm"; @@ -18,21 +17,7 @@ function ItemShow() { const auth = useAuth(); const { id } = useParams(); - const item = use(cache(`/api/items/${id}`)); - - /* - Safety guard: - - If the item is missing at this stage, it means: - - The route does not exist - - OR the user does not have access - - OR the data is stale - - Throwing allows the router error boundary to handle the 404. - */ - if (item == null) { - throw new NotFoundError(); - } + const item = use(cache(`/api/items/${id}`)); return ( <> diff --git a/src/react/helpers/cache.ts b/src/react/helpers/cache.ts index 2b58d07..165b08d 100644 --- a/src/react/helpers/cache.ts +++ b/src/react/helpers/cache.ts @@ -14,7 +14,7 @@ /* In-memory cache used by React `use`. */ -const cacheData = new Map>(); +const cacheData = new Map>(); /* cache(url): @@ -22,21 +22,20 @@ const cacheData = new Map>(); - Fetch is triggered only once per URL - Subsequent calls reuse the same Promise */ -export const cache = (url: string) => { +export const cache = (url: string): Promise => { if (!cacheData.has(url)) { cacheData.set( url, - fetch(url).then((response) => { + fetch(url).then((response) => { if (!response.ok) { - return null; + throw new Error(`${response.status}: ${response.statusText}`); } return response.json(); }), ); } - // biome-ignore lint/style/noNonNullAssertion: cacheData is set before get - return cacheData.get(url)!; + return cacheData.get(url) as Promise; }; /* diff --git a/src/react/helpers/mutate.ts b/src/react/helpers/mutate.ts index 1f57af2..890ae7c 100644 --- a/src/react/helpers/mutate.ts +++ b/src/react/helpers/mutate.ts @@ -76,7 +76,13 @@ export const apiMutate = async ( init.body = JSON.stringify(body); } - return fetch(url, init); + const response = await fetch(url, init); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + return response; }; /* ************************************************************************ */ @@ -104,12 +110,10 @@ export function useMutate() { ) => { const response = await apiMutate(url, method, body); - if (response.ok) { - for (const path of invalidatePaths) { - invalidateCache(path); - } - refresh(); + for (const path of invalidatePaths) { + invalidateCache(path); } + refresh(); return response; }; diff --git a/src/react/routes.tsx b/src/react/routes.tsx index 5e007a1..45218dd 100644 --- a/src/react/routes.tsx +++ b/src/react/routes.tsx @@ -21,8 +21,10 @@ import { type RouteObject, useLoaderData } from "react-router"; -import LogoutForm from "./components/auth/LogoutForm"; +import AccountPage from "./components/auth/AccountPage"; +import { AuthProvider } from "./components/auth/AuthContext"; import VerifyPage from "./components/auth/VerifyPage"; +import { DataRefreshProvider } from "./components/DataRefreshContext"; import ErrorPage from "./components/ErrorPage"; import Home from "./components/Home"; import { itemRoutes } from "./components/item/index"; @@ -34,8 +36,6 @@ import Layout from "./components/Layout"; across all routes and layouts */ import "./index.css"; -import { AuthProvider } from "./components/auth/AuthContext"; -import { DataRefreshProvider } from "./components/DataRefreshContext"; /* ************************************************************************ */ /* Routes definition */ @@ -64,11 +64,11 @@ const routes: RouteObject[] = [ errorElement: , /* Root loader: - - Fetches the current user from the /api/me endpoint + - Fetches the current user from the /api/users/me endpoint - Returns the user to the root component */ loader: async () => { - const response = await fetch("/api/me"); + const response = await fetch("/api/users/me"); const me: User | null = response.ok ? await response.json() : null; @@ -85,8 +85,8 @@ const routes: RouteObject[] = [ element: , }, { - path: "logout", - element: , + path: "account", + element: , }, { path: "verify", diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 67174c2..15347f5 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,5 +1,18 @@ declare module "*.css"; +type Json = + | string + | number + | bigint + | boolean + | null + | undefined + | JsonObject + | JsonArray; + +type JsonObject = { [key: string]: Json }; +type JsonArray = Json[]; + type RowId = number | bigint; type Item = { diff --git a/tests/contracts.ts b/tests/contracts.ts deleted file mode 100644 index bcfc432..0000000 --- a/tests/contracts.ts +++ /dev/null @@ -1,450 +0,0 @@ -/* - Purpose: - Define the API contracts for the StartER framework. - - What are Contracts? - - A central, declarative "Point of Truth" for API behavior. - - Separates test data (what?) from test logic (how?). - - Serves as living documentation for developers and students. - - Structure: - - Contract: a collection of related Tests (e.g., "items", "auth") - - Test: a specific endpoint and method - - Case: a scenario (e.g., "success", "unauthorized", "bad_request") - - Conventions: - - Ordered by status code (ascending) - - Error names follow HTTP status text in snake_case -*/ - -import { cookies } from "supertest"; - -import { allItems, allUsers, barUser, deletedUser, fooUser } from "./data"; - -/* ************************************************************************ */ -/* Types */ -/* ************************************************************************ */ - -export type Json = - | string - | number - | bigint - | boolean - | null - | undefined - | JsonObject - | JsonArray; - -export type JsonObject = { [key: string]: Json }; -export type JsonArray = Json[]; - -export type Case = { - only?: boolean; - // Optional path override (useful for IDs) - specialPath?: string; - request: { - body?: JsonObject; - // Mocked JWT payload to simulate different users - jwtPayload?: { sub: RowId | string } | null; - // Explicitly bypass CSRF to test protection - withoutCsrfProtection?: boolean; - }; - response: { - status: number; - body?: JsonObject | JsonArray; - // Optional hook to run extra assertions on the response - and?: (response: { headers: { [key: string]: string } }) => void; - }; -}; - -export type Test = { - method: "get" | "post" | "put" | "delete"; - path: string; - cases: Record; -}; - -export type Contract = Record; - -/* ************************************************************************ */ -/* Contracts Definitions */ -/* ************************************************************************ */ - -export const contracts: Record = { - auth: { - magic_link: { - method: "post" as const, - path: "/api/auth/magic-link", - cases: { - success: { - request: { - body: { - email: fooUser.email, - }, - }, - response: { - status: 204, - body: {}, - }, - }, - new_user: { - request: { - body: { email: "new_user@mail.com" }, - }, - response: { - status: 204, - body: {}, - }, - }, - bad_request: { - request: { body: {} }, - response: { status: 400, body: expect.any(Object) }, - }, - }, - }, - me: { - method: "get" as const, - path: "/api/me", - cases: { - success: { - request: { - jwtPayload: { sub: fooUser.id }, - }, - response: { - status: 200, - body: fooUser, - }, - }, - guest: { - request: {}, - response: { status: 401, body: {} }, - }, - unauthorized: { - request: { jwtPayload: { sub: NaN } }, - response: { status: 401, body: {} }, - }, - }, - }, - verify: { - method: "post" as const, - path: "/api/auth/verify", - cases: { - success: { - request: { - body: { - token: "success_token", - }, - }, - response: { - status: 201, - body: fooUser, - and: () => { - expect( - cookies.set({ - name: "__Host-auth", - options: { - httpOnly: true, - sameSite: "strict", - secure: true, - path: "/", - }, - }), - ); - }, - }, - }, - bad_request: { - request: { body: {} }, - response: { - status: 400, - body: expect.any(Object), - and: () => { - expect( - cookies.not("set", { - name: "__Host-auth", - }), - ); - }, - }, - }, - unauthorized: { - request: { body: { token: "invalid_token" } }, - response: { - status: 401, - body: {}, - and: () => { - expect( - cookies.not("set", { - name: "__Host-auth", - }), - ); - }, - }, - }, - consumed: { - request: { body: { token: "consumed_token" } }, - response: { - status: 401, - body: {}, - and: () => { - expect( - cookies.not("set", { - name: "__Host-auth", - }), - ); - }, - }, - }, - expired: { - request: { body: { token: "expired_token" } }, - response: { - status: 401, - body: {}, - and: () => { - expect( - cookies.not("set", { - name: "__Host-auth", - }), - ); - }, - }, - }, - deleted_user: { - request: { - body: { token: "deleted_token" }, - jwtPayload: { sub: "deleted@mail.com" }, - }, - response: { - status: 401, - body: {}, - and: () => { - expect( - cookies.not("set", { - name: "__Host-auth", - }), - ); - }, - }, - }, - }, - }, - logout: { - method: "post" as const, - path: "/api/auth/logout", - cases: { - anyone: { - request: {}, - response: { - status: 204, - body: {}, - }, - }, - }, - }, - }, - health: { - get: { - method: "get", - path: "/api/health", - cases: { - success: { - request: {}, - response: { status: 200, body: { hello: "world" } }, - }, - }, - }, - post: { - method: "post", - path: "/api/health", - cases: { - success: { - request: { body: { foo: "bar" } }, - response: { status: 200, body: { foo: "bar" } }, - }, - unauthorized: { - request: { body: { foo: "bar" }, withoutCsrfProtection: true }, - response: { status: 401, body: {} }, - }, - }, - }, - }, - items: { - browse: { - method: "get", - path: "/api/items", - cases: { - success: { - request: {}, - response: { status: 200, body: allItems }, - }, - }, - }, - create: { - method: "post", - path: "/api/items", - cases: { - success: { - request: { - body: { title: "new item" }, - jwtPayload: { sub: fooUser.id }, - }, - response: { status: 201, body: { insertId: expect.any(Number) } }, - }, - bad_request: { - request: { body: {}, jwtPayload: { sub: fooUser.id } }, - response: { status: 400, body: expect.any(Array) }, - }, - unauthorized: { - request: { body: { title: "new item" }, jwtPayload: null }, - response: { status: 401, body: {} }, - }, - }, - }, - delete: { - method: "delete", - path: `/api/items/${allItems[0].id}`, - cases: { - success: { - request: { jwtPayload: { sub: fooUser.id } }, - response: { status: 204, body: {} }, - }, - unauthorized: { - request: { jwtPayload: null }, - response: { status: 401, body: {} }, - }, - forbidden: { - request: { jwtPayload: { sub: barUser.id } }, - response: { status: 403, body: {} }, - }, - not_found: { - specialPath: `/api/items/${NaN}`, - request: { jwtPayload: { sub: fooUser.id } }, - response: { status: 204, body: {} }, - }, - }, - }, - edit: { - method: "put", - path: `/api/items/${allItems[0].id}`, - cases: { - success: { - request: { - body: { title: "updated" }, - jwtPayload: { sub: allItems[0].user_id }, - }, - response: { status: 204, body: {} }, - }, - forbidden: { - request: { - body: { title: "updated" }, - jwtPayload: { sub: barUser.id }, - }, - response: { status: 403, body: {} }, - }, - not_found: { - specialPath: `/api/items/${NaN}`, - request: { - body: { title: "updated" }, - jwtPayload: { sub: fooUser.id }, - }, - response: { status: 404, body: {} }, - }, - }, - }, - read: { - method: "get", - path: `/api/items/${allItems[0].id}`, - cases: { - success: { - request: {}, - response: { status: 200, body: allItems[0] }, - }, - not_found: { - specialPath: `/api/items/${NaN}`, - request: {}, - response: { status: 404, body: {} }, - }, - }, - }, - }, - users: { - browse: { - method: "get", - path: "/api/users", - cases: { - success: { - request: {}, - response: { - status: 200, - body: allUsers.filter((user) => user.id !== deletedUser.id), - }, - }, - }, - }, - delete: { - method: "delete", - path: `/api/users/${fooUser.id}`, - cases: { - success: { - request: { jwtPayload: { sub: fooUser.id } }, - response: { status: 204, body: {} }, - }, - unauthorized: { - request: { jwtPayload: null }, - response: { status: 401, body: {} }, - }, - forbidden: { - request: { jwtPayload: { sub: barUser.id } }, - response: { status: 403, body: {} }, - }, - not_found: { - specialPath: `/api/users/${NaN}`, - request: { jwtPayload: { sub: fooUser.id } }, - response: { status: 204, body: {} }, - }, - }, - }, - edit: { - method: "put", - path: `/api/users/${fooUser.id}`, - cases: { - success: { - request: { - body: { email: "updated@mail.com", name: "updated" }, - jwtPayload: { sub: fooUser.id }, - }, - response: { status: 204, body: {} }, - }, - forbidden: { - request: { - body: { email: "updated@mail.com", name: "updated" }, - jwtPayload: { sub: barUser.id }, - }, - response: { status: 403, body: {} }, - }, - not_found: { - specialPath: `/api/users/${NaN}`, - request: { - body: { email: "updated@mail.com", name: "updated" }, - jwtPayload: { sub: fooUser.id }, - }, - response: { status: 404, body: {} }, - }, - }, - }, - read: { - method: "get", - path: `/api/users/${fooUser.id}`, - cases: { - success: { - request: {}, - response: { status: 200, body: fooUser }, - }, - not_found: { - specialPath: `/api/users/${NaN}`, - request: {}, - response: { status: 404, body: {} }, - }, - }, - }, - }, -}; diff --git a/tests/contracts/auth.ts b/tests/contracts/auth.ts new file mode 100644 index 0000000..dc1174a --- /dev/null +++ b/tests/contracts/auth.ts @@ -0,0 +1,152 @@ +import { cookies } from "supertest"; + +import { fooUser } from "../fixtures/users"; + +export default ({ + magic_link: { + method: "post", + path: "/api/auth/magic-link", + cases: { + success: { + request: { + body: { + email: fooUser.email, + }, + }, + response: { + status: 204, + body: {}, + }, + }, + new_user: { + request: { + body: { email: "new_user@mail.com" }, + }, + response: { + status: 204, + body: {}, + }, + }, + bad_request: { + request: { body: {} }, + response: { status: 400, body: expect.any(Object) }, + }, + }, + }, + verify: { + method: "post", + path: "/api/auth/verify", + cases: { + success: { + request: { + body: { + token: "success_token", + }, + }, + response: { + status: 201, + body: fooUser, + and: () => { + expect( + cookies.set({ + name: "__Host-auth", + options: { + httpOnly: true, + sameSite: "strict", + secure: true, + path: "/", + }, + }), + ); + }, + }, + }, + bad_request: { + request: { body: {} }, + response: { + status: 400, + body: expect.any(Object), + and: () => { + expect( + cookies.not("set", { + name: "__Host-auth", + }), + ); + }, + }, + }, + unauthorized: { + request: { body: { token: "invalid_token" } }, + response: { + status: 401, + body: {}, + and: () => { + expect( + cookies.not("set", { + name: "__Host-auth", + }), + ); + }, + }, + }, + consumed: { + request: { body: { token: "consumed_token" } }, + response: { + status: 401, + body: {}, + and: () => { + expect( + cookies.not("set", { + name: "__Host-auth", + }), + ); + }, + }, + }, + expired: { + request: { body: { token: "expired_token" } }, + response: { + status: 401, + body: {}, + and: () => { + expect( + cookies.not("set", { + name: "__Host-auth", + }), + ); + }, + }, + }, + deleted_user: { + request: { + body: { token: "deleted_token" }, + jwtPayload: { sub: "deleted@mail.com" }, + }, + response: { + status: 401, + body: {}, + and: () => { + expect( + cookies.not("set", { + name: "__Host-auth", + }), + ); + }, + }, + }, + }, + }, + logout: { + method: "post", + path: "/api/auth/logout", + cases: { + anyone: { + request: {}, + response: { + status: 204, + body: {}, + }, + }, + }, + }, +}); diff --git a/tests/contracts/health.ts b/tests/contracts/health.ts new file mode 100644 index 0000000..346c4bf --- /dev/null +++ b/tests/contracts/health.ts @@ -0,0 +1,40 @@ +export default ({ + get: { + method: "get", + path: "/api/health", + cases: { + success: { + request: {}, + response: { status: 200, body: { hello: "world" } }, + }, + }, + }, + post: { + method: "post", + path: "/api/health", + cases: { + success: { + request: { body: { hello: "world" } }, + response: { status: 200, body: { hello: "world" } }, + }, + unauthorized: { + request: { body: { hello: "world" }, withoutCsrfProtection: true }, + response: { status: 401, body: {} }, + }, + }, + }, + delete: { + method: "delete", + path: "/api/health", + cases: { + success: { + request: {}, + response: { status: 204, body: {} }, + }, + unauthorized: { + request: { withoutCsrfProtection: true }, + response: { status: 401, body: {} }, + }, + }, + }, +}); diff --git a/tests/contracts/index.ts b/tests/contracts/index.ts new file mode 100644 index 0000000..a36c779 --- /dev/null +++ b/tests/contracts/index.ts @@ -0,0 +1,36 @@ +/* + Purpose: + Define the API contracts for the StartER framework. + + What are Contracts? + - A central, declarative "Point of Truth" for API behavior. + - Separates test data (what?) from test logic (how?). + - Serves as living documentation for developers and students. + + Structure: + - Contract: a collection of related Tests (e.g., "items", "auth") + - Test: a specific endpoint and method + - Case: a scenario (e.g., "success", "unauthorized", "bad_request") + + Conventions: + - Ordered by status code (ascending) + - Error names follow HTTP status text in snake_case +*/ + +import path from "node:path"; + +const context = import.meta.glob("./*.ts", { + import: "default", + eager: true, +}); + +const contracts = Object.entries(context).reduce>( + (acc, [fileName, contract]) => { + const contractName = path.basename(fileName, ".ts"); + acc[contractName] = contract; + return acc; + }, + {}, +); + +export default contracts; diff --git a/tests/contracts/items.ts b/tests/contracts/items.ts new file mode 100644 index 0000000..c2119fe --- /dev/null +++ b/tests/contracts/items.ts @@ -0,0 +1,102 @@ +import { allItems } from "../fixtures/items"; +import { barUser, fooUser } from "../fixtures/users"; + +export default ({ + browse: { + method: "get", + path: "/api/items", + cases: { + success: { + request: {}, + response: { status: 200, body: allItems }, + }, + }, + }, + create: { + method: "post", + path: "/api/items", + cases: { + success: { + request: { + body: { title: "new item" }, + jwtPayload: { sub: fooUser.id }, + }, + response: { status: 201, body: { insertId: expect.any(Number) } }, + }, + bad_request: { + request: { body: {}, jwtPayload: { sub: fooUser.id } }, + response: { status: 400, body: expect.any(Array) }, + }, + unauthorized: { + request: { body: { title: "new item" }, jwtPayload: null }, + response: { status: 401, body: {} }, + }, + }, + }, + delete: { + method: "delete", + path: `/api/items/${allItems[0].id}`, + cases: { + success: { + request: { jwtPayload: { sub: fooUser.id } }, + response: { status: 204, body: {} }, + }, + unauthorized: { + request: { jwtPayload: null }, + response: { status: 401, body: {} }, + }, + forbidden: { + request: { jwtPayload: { sub: barUser.id } }, + response: { status: 403, body: {} }, + }, + not_found: { + specialPath: `/api/items/${NaN}`, + request: { jwtPayload: { sub: fooUser.id } }, + response: { status: 204, body: {} }, + }, + }, + }, + edit: { + method: "put", + path: `/api/items/${allItems[0].id}`, + cases: { + success: { + request: { + body: { title: "updated" }, + jwtPayload: { sub: allItems[0].user_id }, + }, + response: { status: 204, body: {} }, + }, + forbidden: { + request: { + body: { title: "updated" }, + jwtPayload: { sub: barUser.id }, + }, + response: { status: 403, body: {} }, + }, + not_found: { + specialPath: `/api/items/${NaN}`, + request: { + body: { title: "updated" }, + jwtPayload: { sub: fooUser.id }, + }, + response: { status: 404, body: {} }, + }, + }, + }, + read: { + method: "get", + path: `/api/items/${allItems[0].id}`, + cases: { + success: { + request: {}, + response: { status: 200, body: allItems[0] }, + }, + not_found: { + specialPath: `/api/items/${NaN}`, + request: {}, + response: { status: 404, body: {} }, + }, + }, + }, +}); diff --git a/tests/contracts/users.ts b/tests/contracts/users.ts new file mode 100644 index 0000000..05602f8 --- /dev/null +++ b/tests/contracts/users.ts @@ -0,0 +1,54 @@ +import { fooUser } from "../fixtures/users"; + +export default ({ + read_me: { + method: "get", + path: "/api/users/me", + cases: { + as_me: { + request: { + jwtPayload: { sub: fooUser.id }, + }, + response: { + status: 200, + body: fooUser, + }, + }, + unauthorized: { + request: {}, + response: { status: 401, body: {} }, + }, + invalid_user_id: { + request: { jwtPayload: { sub: NaN } }, + response: { status: 401, body: {} }, + }, + }, + }, + edit_me: { + method: "put", + path: "/api/users/me", + cases: { + as_me: { + request: { + body: { email: "updated@mail.com", name: "updated" }, + jwtPayload: { sub: fooUser.id }, + }, + response: { status: 204, body: {} }, + }, + }, + }, + delete_me: { + method: "delete", + path: "/api/users/me", + cases: { + as_me: { + request: { jwtPayload: { sub: fooUser.id } }, + response: { status: 204, body: {} }, + }, + unauthorized: { + request: { jwtPayload: null }, + response: { status: 401, body: {} }, + }, + }, + }, +}); diff --git a/tests/express/contracts.test.ts b/tests/express/contracts.test.ts index c502115..a94b8a5 100644 --- a/tests/express/contracts.test.ts +++ b/tests/express/contracts.test.ts @@ -13,7 +13,7 @@ - Decouples API contract from implementation details */ -import { contracts } from "../contracts"; +import contracts from "../contracts"; import { check, setupMocks } from "./test-utils"; describe("API Contracts", () => { diff --git a/tests/express/test-utils.ts b/tests/express/test-utils.ts index a050a3e..1c3abdb 100644 --- a/tests/express/test-utils.ts +++ b/tests/express/test-utils.ts @@ -7,14 +7,15 @@ import jwt, { type JwtPayload } from "jsonwebtoken"; import supertest from "supertest"; import database from "../../src/database"; + +import { allItems } from "../fixtures/items"; import { - allItems, allUsers, barUser, bazUser, deletedUser, fooUser, -} from "../data"; +} from "../fixtures/users"; // ------------------------- // DB mock @@ -138,7 +139,7 @@ vi.mock("nodemailer", async (importActual) => { // Helpers // ------------------------- -import { type Contract, contracts, type Test } from "../contracts"; +import contracts from "../contracts"; export const setupMocks = () => { mockDatabase(); diff --git a/tests/fixtures/items.ts b/tests/fixtures/items.ts new file mode 100644 index 0000000..d25f702 --- /dev/null +++ b/tests/fixtures/items.ts @@ -0,0 +1,22 @@ +/* + Purpose: + Centralize all mocked data for both API and React tests. + This ensures consistency and eliminates duplication. + + Naming: + Use descriptive names (e.g., teacherUser, mainPlay) to make tests more readable. +*/ +import { barUser, fooUser } from "./users"; + +export const allItems: Item[] = [ + { + id: 1, + title: "Stuff", + user_id: fooUser.id, + }, + { + id: 2, + title: "Doodads", + user_id: barUser.id, + }, +]; diff --git a/tests/data.ts b/tests/fixtures/users.ts similarity index 60% rename from tests/data.ts rename to tests/fixtures/users.ts index cdbdac2..1b87b0e 100644 --- a/tests/data.ts +++ b/tests/fixtures/users.ts @@ -7,10 +7,6 @@ Use descriptive names (e.g., teacherUser, mainPlay) to make tests more readable. */ -// --------------------------------------------------------- -// Users -// --------------------------------------------------------- - export const allUsers: User[] = [ { id: 1, @@ -38,20 +34,3 @@ export const fooUser = allUsers[0]; export const barUser = allUsers[1]; export const bazUser = allUsers[2]; export const deletedUser = allUsers[3]; - -// --------------------------------------------------------- -// Items -// --------------------------------------------------------- - -export const allItems: Item[] = [ - { - id: 1, - title: "Stuff", - user_id: fooUser.id, - }, - { - id: 2, - title: "Doodads", - user_id: barUser.id, - }, -]; diff --git a/tests/react/components/ErrorPage.test.tsx b/tests/react/components/ErrorPage.test.tsx index 82861e0..84c138c 100644 --- a/tests/react/components/ErrorPage.test.tsx +++ b/tests/react/components/ErrorPage.test.tsx @@ -1,7 +1,6 @@ import { screen } from "@testing-library/react"; import { data } from "react-router"; -import { HttpError, NotFoundError } from "../../../src/errors/HttpError"; import ErrorPage from "../../../src/react/components/ErrorPage"; import { renderWithStub, setupMocks } from "../test-utils"; @@ -44,36 +43,6 @@ describe("", () => { await screen.findByText("Test error"); }); - it("should handle a 404 error", async () => { - await renderWithStub({ - path: "/", - Component: () => { - throw new NotFoundError(); - }, - ErrorBoundary: ErrorPage, - initialEntries: ["/"], - me: null, - }); - - await screen.findByRole("heading", { level: 1 }); - await screen.findByText("Not Found"); - }); - - it("should handle an HTTP error response", async () => { - await renderWithStub({ - path: "/", - Component: () => { - throw new HttpError(418, "I'm a teapot"); - }, - ErrorBoundary: ErrorPage, - initialEntries: ["/"], - me: null, - }); - - await screen.findByRole("heading", { level: 1 }); - await screen.findByText("I'm a teapot"); - }); - it("should handle a standard error", async () => { await renderWithStub({ path: "/", diff --git a/tests/react/components/Layout.test.tsx b/tests/react/components/Layout.test.tsx index a4e074c..ef40861 100644 --- a/tests/react/components/Layout.test.tsx +++ b/tests/react/components/Layout.test.tsx @@ -1,6 +1,7 @@ import { screen } from "@testing-library/react"; import Layout from "../../../src/react/components/Layout"; -import { fooUser, renderWithStub, setupMocks } from "../test-utils"; +import { fooUser } from "../../fixtures/users"; +import { renderWithStub, setupMocks } from "../test-utils"; describe("", () => { beforeEach(() => { @@ -34,7 +35,7 @@ describe("", () => { await screen.findByLabelText(/email/i); }); - it("should render logout when authenticated", async () => { + it("should render account link when authenticated", async () => { await renderWithStub({ path: "/", Component: () => , @@ -42,6 +43,6 @@ describe("", () => { me: fooUser, }); - await screen.findByText(/logout/i); + await screen.findByText(/account/i); }); }); diff --git a/tests/react/components/auth/AccountPage.test.tsx b/tests/react/components/auth/AccountPage.test.tsx new file mode 100644 index 0000000..bbb5330 --- /dev/null +++ b/tests/react/components/auth/AccountPage.test.tsx @@ -0,0 +1,119 @@ +import { act, fireEvent, screen } from "@testing-library/react"; + +import AccountPage from "../../../../src/react/components/auth/AccountPage"; +import { fooUser } from "../../../fixtures/users"; +import { + expectContractCall, + renderWithStub, + requestValue, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/", + Component: AccountPage, + initialEntries: ["/"], + me: fooUser, + }); + + await screen.findByRole("heading", { level: 1, name: /account/i }); + }); + + it("should submit account details form", async () => { + const { user } = await renderWithStub({ + path: "/", + Component: AccountPage, + initialEntries: ["/"], + me: fooUser, + }); + + await user.clear(screen.getByRole("textbox", { name: /email/i })); + await user.type( + screen.getByRole("textbox", { name: /email/i }), + String(requestValue("users", "edit_me", "as_me", "email")), + ); + await user.clear(screen.getByRole("textbox", { name: /name/i })); + await user.type( + screen.getByRole("textbox", { name: /name/i }), + String(requestValue("users", "edit_me", "as_me", "name")), + ); + await user.click(screen.getByRole("button", { name: /save/i })); + + expectContractCall("users", "edit_me", "as_me"); + }); + + it("should alert when submitted data is invalid", async () => { + vi.spyOn(window, "alert").mockImplementationOnce(() => {}); + + const { user } = await renderWithStub({ + path: "/", + Component: AccountPage, + initialEntries: ["/"], + me: fooUser, + }); + + await user.clear(screen.getByRole("textbox", { name: /email/i })); + await user.clear(screen.getByRole("textbox", { name: /name/i })); + await act(async () => { + await fireEvent.submit(screen.getByRole("form")); + }); + + expect(alert).toHaveBeenCalled(); + }); + + it("should submit logout form", async () => { + const { user } = await renderWithStub({ + path: "/", + Component: AccountPage, + initialEntries: ["/"], + me: fooUser, + }); + + await user.click(screen.getByRole("button", { name: /log out/i })); + + expectContractCall("auth", "logout", "anyone"); + }); + + it("should submit delete form", async () => { + vi.spyOn(window, "confirm").mockReturnValueOnce(true); + + const { user } = await renderWithStub({ + path: "/", + Component: AccountPage, + initialEntries: ["/"], + me: fooUser, + }); + + await user.click(screen.getByRole("button", { name: /delete/i })); + + expectContractCall("users", "delete_me", "as_me"); + }); + + it("should not submit delete form when user cancels", async () => { + vi.spyOn(window, "confirm").mockReturnValueOnce(false); + + const { user } = await renderWithStub({ + path: "/", + Component: AccountPage, + initialEntries: ["/"], + me: fooUser, + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockClear(); + + await user.click(screen.getByRole("button", { name: /delete/i })); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/auth/AuthContext.test.tsx b/tests/react/components/auth/AuthContext.test.tsx index ef1d41a..a084e21 100644 --- a/tests/react/components/auth/AuthContext.test.tsx +++ b/tests/react/components/auth/AuthContext.test.tsx @@ -105,26 +105,6 @@ describe("React Components: AuthContext", () => { await act(async () => await auth.logout()); - expectContractCall("auth", "logout", "anyone"); - }); - it("should throw when logout fails", async () => { - setupMocks({ - force500: [ - { - path: "/api/auth/logout", - method: "post", - }, - ], - }); - - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - await expect(auth.logout()).rejects.toThrow(/logout/i); - expectContractCall("auth", "logout", "anyone"); }); }); diff --git a/tests/react/components/auth/Logout.test.tsx b/tests/react/components/auth/Logout.test.tsx deleted file mode 100644 index d9750a5..0000000 --- a/tests/react/components/auth/Logout.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { screen } from "@testing-library/react"; - -import LogoutForm from "../../../../src/react/components/auth/LogoutForm"; -import { - expectContractCall, - fooUser, - renderWithStub, - setupMocks, -} from "../../test-utils"; - -describe("", () => { - beforeEach(() => { - setupMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("should mount successfully", async () => { - await renderWithStub({ - path: "/", - Component: LogoutForm, - initialEntries: ["/"], - me: fooUser, - }); - await screen.findByRole("button"); - }); - it("should submit form logout", async () => { - const { user } = await renderWithStub({ - path: "/", - Component: LogoutForm, - initialEntries: ["/"], - me: fooUser, - }); - await screen.findByRole("button"); - - await user.click(screen.getByRole("button")); - - expectContractCall("auth", "logout", "anyone"); - }); -}); diff --git a/tests/react/components/item/ItemCreate.test.tsx b/tests/react/components/item/ItemCreate.test.tsx index 06eca77..5366644 100644 --- a/tests/react/components/item/ItemCreate.test.tsx +++ b/tests/react/components/item/ItemCreate.test.tsx @@ -2,10 +2,9 @@ import { screen } from "@testing-library/react"; import * as ReactRouter from "react-router"; import ItemCreate from "../../../../src/react/components/item/ItemCreate"; - +import { fooUser } from "../../../fixtures/users"; import { expectContractCall, - fooUser, renderWithStub, requestValue, responseValue, @@ -58,32 +57,4 @@ describe("", () => { `/items/${responseValue("items", "create", "success", "insertId")}`, ); }); - it("should not redirect if server returns an error", async () => { - setupMocks({ - force500: [ - { - path: "/api/items", - method: "post", - }, - ], - }); - - const { user } = await renderWithStub({ - path: "/items/new", - Component: ItemCreate, - initialEntries: ["/items/new"], - me: fooUser, - }); - - await user.type( - screen.getByLabelText(/title/i), - String(requestValue("items", "create", "success", "title")), - ); - await user.click(screen.getByRole("button")); - - expectContractCall("items", "create", "success"); - - const navigate = ReactRouter.useNavigate(); - expect(navigate).not.toHaveBeenCalled(); - }); }); diff --git a/tests/react/components/item/ItemDeleteForm.test.tsx b/tests/react/components/item/ItemDeleteForm.test.tsx index 5875e71..3705ff9 100644 --- a/tests/react/components/item/ItemDeleteForm.test.tsx +++ b/tests/react/components/item/ItemDeleteForm.test.tsx @@ -2,11 +2,10 @@ import { screen } from "@testing-library/react"; import * as ReactRouter from "react-router"; import ItemDeleteForm from "../../../../src/react/components/item/ItemDeleteForm"; - +import { allItems } from "../../../fixtures/items"; +import { fooUser } from "../../../fixtures/users"; import { - allItems, expectContractCall, - fooUser, renderWithStub, setupMocks, } from "../../test-utils"; @@ -51,28 +50,4 @@ describe("", () => { const navigate = ReactRouter.useNavigate(); expect(navigate).toHaveBeenCalledWith("/items"); }); - it("should not redirect when server returns an error", async () => { - setupMocks({ - force500: [ - { - path: `/api/items/${allItems[0].id}`, - method: "delete", - }, - ], - }); - - const { user } = await renderWithStub({ - path: "/items/:id", - Component: ItemDeleteForm, - initialEntries: [`/items/${allItems[0].id}`], - me: fooUser, - }); - - await user.click(screen.getByRole("button")); - - expectContractCall("items", "delete", "success"); - - const navigate = ReactRouter.useNavigate(); - expect(navigate).not.toHaveBeenCalled(); - }); }); diff --git a/tests/react/components/item/ItemEdit.test.tsx b/tests/react/components/item/ItemEdit.test.tsx index c68084d..aa6a548 100644 --- a/tests/react/components/item/ItemEdit.test.tsx +++ b/tests/react/components/item/ItemEdit.test.tsx @@ -2,11 +2,10 @@ import { screen } from "@testing-library/react"; import * as ReactRouter from "react-router"; import ItemEdit from "../../../../src/react/components/item/ItemEdit"; - +import { allItems } from "../../../fixtures/items"; +import { fooUser } from "../../../fixtures/users"; import { - allItems, expectContractCall, - fooUser, renderWithStub, requestValue, setupMocks, @@ -45,7 +44,7 @@ describe("", () => { initialEntries: [`/items/${NaN}/edit`], me: fooUser, }), - ).rejects.toThrow(/not found/i); + ).rejects.toThrow(/404/i); }); it("should submit form and edit an item", async () => { const { user } = await renderWithStub({ @@ -67,33 +66,4 @@ describe("", () => { const navigate = ReactRouter.useNavigate(); expect(navigate).toHaveBeenCalledWith(`/items/${allItems[0].id}`); }); - it("should not redirect when server returns an error", async () => { - setupMocks({ - force500: [ - { - path: `/api/items/${allItems[0].id}`, - method: "put", - }, - ], - }); - - const { user } = await renderWithStub({ - path: "/items/:id/edit", - Component: ItemEdit, - initialEntries: [`/items/${allItems[0].id}/edit`], - me: fooUser, - }); - - await user.clear(screen.getByLabelText(/title/i)); - await user.type( - screen.getByLabelText(/title/i), - String(requestValue("items", "edit", "success", "title")), - ); - await user.click(screen.getByRole("button")); - - expectContractCall("items", "edit", "success"); - - const navigate = ReactRouter.useNavigate(); - expect(navigate).not.toHaveBeenCalled(); - }); }); diff --git a/tests/react/components/item/ItemForm.test.tsx b/tests/react/components/item/ItemForm.test.tsx index f6c0cb2..27e69c2 100644 --- a/tests/react/components/item/ItemForm.test.tsx +++ b/tests/react/components/item/ItemForm.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, screen } from "@testing-library/react"; import ItemForm from "../../../../src/react/components/item/ItemForm"; - -import { fooUser, renderWithStub, setupMocks } from "../../test-utils"; +import { fooUser } from "../../../fixtures/users"; +import { renderWithStub, setupMocks } from "../../test-utils"; describe("", () => { beforeEach(() => { diff --git a/tests/react/components/item/ItemList.test.tsx b/tests/react/components/item/ItemList.test.tsx index 977271e..d38d40c 100644 --- a/tests/react/components/item/ItemList.test.tsx +++ b/tests/react/components/item/ItemList.test.tsx @@ -1,10 +1,9 @@ import { screen } from "@testing-library/react"; import ItemList from "../../../../src/react/components/item/ItemList"; - +import { fooUser } from "../../../fixtures/users"; import { expectContractCall, - fooUser, renderWithStub, setupMocks, } from "../../test-utils"; diff --git a/tests/react/components/item/ItemShow.test.tsx b/tests/react/components/item/ItemShow.test.tsx index cb2638f..9ffcb29 100644 --- a/tests/react/components/item/ItemShow.test.tsx +++ b/tests/react/components/item/ItemShow.test.tsx @@ -1,11 +1,10 @@ import { screen } from "@testing-library/react"; import ItemShow from "../../../../src/react/components/item/ItemShow"; - +import { allItems } from "../../../fixtures/items"; +import { fooUser } from "../../../fixtures/users"; import { - allItems, expectContractCall, - fooUser, renderWithStub, setupMocks, } from "../../test-utils"; @@ -40,7 +39,7 @@ describe("", () => { initialEntries: [`/items/${NaN}`], me: fooUser, }), - ).rejects.toThrow(/not found/i); + ).rejects.toThrow(/404/i); expectContractCall("items", "read", "not_found"); }); diff --git a/tests/react/helpers/cache.test.ts b/tests/react/helpers/cache.test.ts index 6b7d6fb..c64a5a1 100644 --- a/tests/react/helpers/cache.test.ts +++ b/tests/react/helpers/cache.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cache, invalidateCache } from "../../../src/react/helpers/cache"; -import { allItems, fooUser, setupMocks } from "../test-utils"; +import { setupMocks } from "../test-utils"; describe("React Helpers: cache", () => { beforeEach(() => { @@ -15,89 +15,65 @@ describe("React Helpers: cache", () => { describe("cache()", () => { it("should return cached data", async () => { - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); + const data = await cache("/api/health"); + expect(data).toEqual({ hello: "world" }); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenNthCalledWith(1, `/api/health`); }); it("should not fetch again when data is cached", async () => { - invalidateCache(`/api/items/${allItems[0].id}`); - - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); + const data = await cache(`/api/health`); + const data2 = await cache(`/api/health`); + expect(data2).toEqual(data); expect(global.fetch).toHaveBeenCalledTimes(1); - - const data2 = await cache(`/api/items/${allItems[0].id}`); - expect(data2).toEqual(allItems[0]); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenNthCalledWith( - 1, - `/api/items/${allItems[0].id}`, - ); }); - it("should return null when data is not available", async () => { - const data = await cache("/api/404"); - expect(data).toBeNull(); - expect(global.fetch).toHaveBeenCalledTimes(1); + it("should throw error when data is not available", async () => { + await expect(() => cache("/api/404")).rejects.toThrow(/404/i); }); }); describe("invalidateCache()", () => { it("should invalidate cache", async () => { - invalidateCache(`/api/items/${allItems[0].id}`); + const data = await cache("/api/health"); - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); + invalidateCache("/api/health"); - expect(global.fetch).toHaveBeenCalledTimes(1); - - invalidateCache(`/api/items/${allItems[0].id}`); - - const data2 = await cache(`/api/items/${allItems[0].id}`); - expect(data2).toEqual(allItems[0]); + const data2 = await cache(`/api/health`); + expect(data2).toEqual(data); expect(global.fetch).toHaveBeenCalledTimes(2); - expect(global.fetch).toHaveBeenNthCalledWith( - 2, - `/api/items/${allItems[0].id}`, - ); + expect(global.fetch).toHaveBeenNthCalledWith(2, `/api/health`); }); it("should invalidate all cache when '*' is provided", async () => { - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - const data2 = await cache(`/api/users/${fooUser.id}`); - expect(data2).toEqual(fooUser); - - expect(global.fetch).toHaveBeenCalledTimes(2); + const data = await cache("/api/health"); + const data2 = await cache("/api/users/me"); invalidateCache("*"); - const data3 = await cache(`/api/users/${fooUser.id}`); - expect(data3).toEqual(fooUser); + const data3 = await cache(`/api/health`); + expect(data3).toEqual(data); + const data4 = await cache(`/api/users/me`); + expect(data4).toEqual(data2); - expect(global.fetch).toHaveBeenCalledTimes(3); - expect(global.fetch).toHaveBeenNthCalledWith( - 3, - `/api/users/${fooUser.id}`, - ); + expect(global.fetch).toHaveBeenCalledTimes(4); + expect(global.fetch).toHaveBeenNthCalledWith(1, `/api/health`); + expect(global.fetch).toHaveBeenNthCalledWith(2, `/api/users/me`); + expect(global.fetch).toHaveBeenNthCalledWith(3, `/api/health`); + expect(global.fetch).toHaveBeenNthCalledWith(4, `/api/users/me`); }); it("should not invalidate cache for paths that do not match", async () => { - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - const data2 = await cache(`/api/users/${fooUser.id}`); - expect(data2).toEqual(fooUser); - - expect(global.fetch).toHaveBeenCalledTimes(2); + await cache("/api/health"); + await cache("/api/users/me"); - invalidateCache("/api/items"); + invalidateCache("/api/users/me"); - const data3 = await cache(`/api/users/${fooUser.id}`); - expect(data3).toEqual(fooUser); + const data = await cache(`/api/health`); + expect(data).toEqual({ hello: "world" }); expect(global.fetch).toHaveBeenCalledTimes(2); }); diff --git a/tests/react/helpers/mutate.test.tsx b/tests/react/helpers/mutate.test.tsx index c017eb2..d5a595e 100644 --- a/tests/react/helpers/mutate.test.tsx +++ b/tests/react/helpers/mutate.test.tsx @@ -5,7 +5,6 @@ import * as cache from "../../../src/react/helpers/cache"; import { apiMutate, useMutate } from "../../../src/react/helpers/mutate"; import { expectContractCall, - fooUser, renderHookAsync, requestValue, setupMocks, @@ -23,18 +22,17 @@ describe("React Helpers: mutate", () => { describe("apiMutate()", () => { it("should send a mutation request with a body", async () => { - await apiMutate(`/api/users/${fooUser.id}`, "put", { - email: requestValue("users", "edit", "success", "email"), - name: requestValue("users", "edit", "success", "name"), + await apiMutate(`/api/health`, "post", { + hello: requestValue("health", "post", "success", "hello"), }); - expectContractCall("users", "edit", "success"); + expectContractCall("health", "post", "success"); }); it("should send a mutation request without a body", async () => { - await apiMutate(`/api/users/${fooUser.id}`, "delete"); + await apiMutate("/api/health", "delete"); - expectContractCall("users", "delete", "success"); + expectContractCall("health", "delete", "success"); }); }); @@ -64,19 +62,13 @@ describe("React Helpers: mutate", () => { const mutate = result.current; - await act(() => - mutate(`/api/users/${fooUser.id}`, "delete", null, ["/api/users"]), - ); + await act(() => mutate("/api/health", "delete", null, ["/api/health"])); - expectContractCall("users", "delete", "success"); - expect(invalidateCacheMock).toHaveBeenCalledWith("/api/users"); + expectContractCall("health", "delete", "success"); + expect(invalidateCacheMock).toHaveBeenCalledWith("/api/health"); }); it("should return a mutate function that does not invalidate the cache when the request fails", async () => { - setupMocks({ - force500: [{ path: `/api/users/${fooUser.id}`, method: "delete" }], - }); - const invalidateCacheMock = vi.spyOn(cache, "invalidateCache"); const { result } = await renderHookAsync(() => useMutate(), { wrapper: DataRefreshProvider, @@ -84,11 +76,8 @@ describe("React Helpers: mutate", () => { const mutate = result.current; - await act(() => - mutate(`/api/users/${fooUser.id}`, "delete", null, ["/api/users"]), - ); + await expect(() => mutate("/api/500", "post")).rejects.toThrow(/500/i); - expectContractCall("users", "delete", "success"); expect(invalidateCacheMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/react/test-utils.tsx b/tests/react/test-utils.tsx index a7a4f6d..d30cf99 100644 --- a/tests/react/test-utils.tsx +++ b/tests/react/test-utils.tsx @@ -5,9 +5,7 @@ import { createRoutesStub } from "react-router"; import { AuthProvider } from "../../src/react/components/auth/AuthContext"; import { DataRefreshProvider } from "../../src/react/components/DataRefreshContext"; import { invalidateCache } from "../../src/react/helpers/cache"; -import { type Contract, contracts, type Json, type Test } from "../contracts"; - -export * from "../data"; +import contracts from "../contracts"; // ------------------------- // Fetch mock (contract-based) @@ -110,10 +108,14 @@ const mockFetch = ( } } - if (path === "/api/404" && method === "get") { + if (path === "/api/404") { return respond(null, 404); } + if (path === "/api/500") { + return respond(null, 500); + } + throw new Error( `[Contract Mock] Unhandled fetch: ${method.toUpperCase()} ${path} with ${JSON.stringify(init)}`, ); @@ -179,18 +181,13 @@ const mockedRandomUUID = "a-b-c-d-e"; export const setupMocks = ({ forceCases, - force500, }: { forceCases?: Record<`${string}.${string}`, keyof Test["cases"]>; - force500?: { path: string; method: "get" | "post" | "put" | "delete" }[]; } = {}) => { vi.stubGlobal("cookieStore", { get: vi.fn(), set: vi.fn() }); vi.spyOn(crypto, "randomUUID").mockImplementation(() => mockedRandomUUID); const customFetch = (path: string, method: string) => { - if (force500?.some((f) => f.path === path && f.method === method)) { - return respond(null, 500); - } if (forceCases) { for (const [key, caseName] of Object.entries(forceCases)) { const [contractName, testName] = key.split("."); diff --git a/tests/bin/database-sync.test.ts b/tests/scripts/database-sync.test.ts similarity index 98% rename from tests/bin/database-sync.test.ts rename to tests/scripts/database-sync.test.ts index a24fc8d..c3d220d 100644 --- a/tests/bin/database-sync.test.ts +++ b/tests/scripts/database-sync.test.ts @@ -1,6 +1,6 @@ import { DatabaseSync } from "node:sqlite"; -import { main } from "../../bin/database-sync"; +import { main } from "../../scripts/database-sync"; import database from "../../src/database"; vi.mock("../../src/database", () => ({ diff --git a/tests/bin/make-clone.test.ts b/tests/scripts/make-clone.test.ts similarity index 98% rename from tests/bin/make-clone.test.ts rename to tests/scripts/make-clone.test.ts index cb2db4b..7f96d34 100644 --- a/tests/bin/make-clone.test.ts +++ b/tests/scripts/make-clone.test.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import fs from "fs-extra"; -import { main } from "../../bin/make-clone"; +import { main } from "../../scripts/make-clone"; describe("make-clone.ts", () => { let tmpDir: string; diff --git a/tests/bin/make-purge.test.ts b/tests/scripts/make-purge.test.ts similarity index 95% rename from tests/bin/make-purge.test.ts rename to tests/scripts/make-purge.test.ts index e322204..1cb82a0 100644 --- a/tests/bin/make-purge.test.ts +++ b/tests/scripts/make-purge.test.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import fs from "fs-extra"; -import { main } from "../../bin/make-purge"; +import { main } from "../../scripts/make-purge"; const projectRoot = path.join(import.meta.dirname, "../.."); @@ -12,9 +12,7 @@ const projectRoot = path.join(import.meta.dirname, "../.."); * preventing regressions when the codebase changes. */ async function scaffoldProject(rootDir: string) { - await fs.copy(path.join(projectRoot, "src"), path.join(rootDir, "src"), { - filter: (srcPath) => !srcPath.includes(path.join("database", "data")), - }); + await fs.copy(path.join(projectRoot, "src"), path.join(rootDir, "src")); await fs.copy(path.join(projectRoot, "tests"), path.join(rootDir, "tests")); } @@ -194,7 +192,7 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { ); expect(result).not.toContain("/items"); - expect(result).toContain("/logout"); + expect(result).toContain("/account"); expect(result).toContain("Home"); }); @@ -211,7 +209,7 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { .replace(` ...itemRoutes,\n`, ""); expect(result).not.toContain("itemRoutes"); - expect(result).toContain("LogoutForm"); + expect(result).toContain("AccountPage"); }); it("removes item route from express routes.ts", async () => { @@ -295,7 +293,10 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { ); const result = content - .replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "") + .replace( + `import AccountPage from "./components/auth/AccountPage";\n`, + "", + ) .replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "") .replace( `import { AuthProvider } from "./components/auth/AuthContext";\n`, @@ -311,7 +312,7 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { ) .replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "") .replace( - / {6}\{\n {8}path: "logout",\n {8}element: ,\n {6}\},\n/m, + / {6}\{\n {8}path: "account",\n {8}element: ,\n {6}\},\n/m, "", ) .replace( @@ -319,12 +320,12 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { "", ); - expect(result).not.toContain("LogoutForm"); + expect(result).not.toContain("AccountPage"); expect(result).not.toContain("VerifyPage"); expect(result).not.toContain("AuthProvider"); expect(result).not.toContain("useLoaderData"); expect(result).not.toContain("Root loader"); - expect(result).not.toContain('path: "logout"'); + expect(result).not.toContain('path: "account"'); expect(result).not.toContain('path: "verify"'); expect(result).toContain("DataRefreshProvider"); expect(result).toContain("Layout"); @@ -383,7 +384,7 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { expect(result).not.toContain("useAuth"); expect(result).not.toContain("check()"); expect(result).not.toContain("/items"); - expect(result).not.toContain("/logout"); + expect(result).not.toContain("/account"); expect(result).toContain("Home"); }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..eee6577 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,4 @@ +// This file is required for IDEs to correctly resolve the tsconfig for the test files +{ + "extends": "../tsconfig.test.json" +} diff --git a/tests/types/index.d.ts b/tests/types/index.d.ts new file mode 100644 index 0000000..338662b --- /dev/null +++ b/tests/types/index.d.ts @@ -0,0 +1,26 @@ +type Case = { + only?: boolean; + // Optional path override (useful for IDs) + specialPath?: string; + request: { + body?: JsonObject; + // Mocked JWT payload to simulate different users + jwtPayload?: { sub: RowId | string } | null; + // Explicitly bypass CSRF to test protection + withoutCsrfProtection?: boolean; + }; + response: { + status: number; + body?: JsonObject | JsonArray; + // Optional hook to run extra assertions on the response + and?: (response: { headers: { [key: string]: string } }) => void; + }; +}; + +type Test = { + method: "get" | "post" | "put" | "delete"; + path: string; + cases: Record; +}; + +type Contract = Record; diff --git a/tsconfig.json b/tsconfig.json index 4790190..ebe95fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,34 @@ { "compilerOptions": { - "target": "ES2024", - "useDefineForClassFields": true, - "lib": ["ES2024", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", + "rootDir": ".", "noEmit": true, - "jsx": "react-jsx", + + // Strictness "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "allowJs": true, + + // Module + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "resolveJsonModule": true, + + // JSX + "jsx": "react-jsx", + + // Interop "esModuleInterop": true, - "types": ["vitest/globals", "vite/client"] - } + "isolatedModules": true, + "skipLibCheck": true, + + // Lib + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + + // Types (framework scope only) + "types": ["vite/client"] + }, + "include": ["server.ts", "src/**/*"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..99a2cf4 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +// tsconfig.test.json — extends base, adds test scope +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vite/client", "vitest/globals"] + }, + "include": ["server.ts", "src/**/*", "scripts/**/*", "tests/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts index e1452b2..d838f61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ export default defineConfig(({ mode }) => ({ test: { globals: true, env: loadEnv(mode, process.cwd(), ""), + typecheck: { tsconfig: "./tsconfig.test.json" }, projects: [ { extends: true, @@ -27,8 +28,8 @@ export default defineConfig(({ mode }) => ({ ], coverage: { exclude: [ - "tests/**/contracts.ts", - "tests/**/data.ts", + "tests/**/contracts", + "tests/**/fixtures", "tests/**/test-utils*.ts", ], },