diff --git a/bin/database-sync.ts b/bin/database-sync.ts index 9c65911..139337b 100644 --- a/bin/database-sync.ts +++ b/bin/database-sync.ts @@ -3,30 +3,18 @@ import readline from "node:readline/promises"; import { DatabaseSync } from "node:sqlite"; import fs from "fs-extra"; -// Build the path to the schema SQL file -const schema = path.join(import.meta.dirname, "../src/database/schema.sql"); -const seeder = path.join(import.meta.dirname, "../src/database/seeder.sql"); -const sqlite = path.join( - import.meta.dirname, - "../src/database/data/database.sqlite", -); - -// Setup readline for interactive confirmation. -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -// Asks the user for confirmation. -async function confirm(question: string): Promise { - const answer = await rl.question(`${question} (y/N) `); - return answer.toLowerCase() === "y"; -} +export async function main( + argv: string[] = process.argv, + rootDirOverride?: string, +) { + const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); -let database: DatabaseSync | null = null; + // Build the paths to the schema, seeder and database files + const schema = path.join(rootDir, "src/database/schema.sql"); + const seeder = path.join(rootDir, "src/database/seeder.sql"); + const sqlite = path.join(rootDir, "src/database/data/database.sqlite"); -async function main() { - const args = process.argv.slice(2); + const args = argv.slice(2); const useSeeder = args.includes("--use-seeder"); const noInteraction = @@ -38,67 +26,73 @@ async function main() { ]; if (args.length !== expectedArgs.length) { - console.error( + throw new Error( "Usage: npm run database:sync [-- --use-seeder] [--no-interaction|-n]", ); - process.exit(1); } console.info( `This script will drop existing '${path.normalize(sqlite)}' to create a new one.`, ); - const proceed = async () => { - if (noInteraction) { - console.info( - "Running in non-interactive mode. Proceeding automatically.", - ); - return true; - } else { - return await confirm( - "Are you sure you want to continue? This action cannot be undone.", + if (!noInteraction) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question( + "Are you sure you want to continue? This action cannot be undone. (y/N) ", ); - } - }; - if (!(await proceed())) { - console.info("\nSync operation cancelled."); - return; + if (answer.toLowerCase() !== "y") { + console.info("\nSync operation cancelled."); + return; + } + } finally { + rl.close(); + } } // Delete the existing database file if it exists await fs.remove(sqlite); + // Ensure the parent directory exists + await fs.ensureDir(path.dirname(sqlite)); + // Create a new database with the specified name - database = new DatabaseSync(sqlite); + const database = new DatabaseSync(sqlite); - // Read the SQL statements from the schema file - const sql = await fs.readFile(schema, "utf8"); + try { + // Read the SQL statements from the schema file + const sql = await fs.readFile(schema, "utf8"); - // Execute the SQL statements to update the database schema - database.exec(sql); + // Execute the SQL statements to update the database schema + database.exec(sql); - console.info( - `\nDatabase '${path.normalize(sqlite)}' in sync with '${path.normalize(schema)}' 🆙`, - ); + console.info( + `\nDatabase '${path.normalize(sqlite)}' in sync with '${path.normalize(schema)}' 🆙`, + ); - if (useSeeder) { - // Read the SQL statements from the seeder file - const sql = await fs.readFile(seeder, "utf8"); + if (useSeeder) { + // Read the SQL statements from the seeder file + const sql = await fs.readFile(seeder, "utf8"); - // Execute the SQL statements to seed the database - database.exec(sql); + // Execute the SQL statements to seed the database + database.exec(sql); - console.info(`\nSeeded using '${path.normalize(seeder)}' 🌱`); + console.info(`\nSeeded using '${path.normalize(seeder)}' 🌱`); + } + } finally { + database.close(); } } -main() - .catch((err) => { - console.error("An unexpected error occurred:", err); +/* v8 ignore next 6 */ +if (process.env.NODE_ENV !== "test") { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); process.exit(1); - }) - .finally(() => { - rl.close(); - database?.close(); }); +} diff --git a/bin/make-purge.ts b/bin/make-purge.ts index db3d40b..3306923 100644 --- a/bin/make-purge.ts +++ b/bin/make-purge.ts @@ -3,23 +3,12 @@ import readline from "node:readline/promises"; import fs from "fs-extra"; -// Locate the project root directory (one level up from /bin). -const rootDir = path.join(import.meta.dirname, ".."); - -// Setup readline for interactive confirmation. -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -// Asks the user for confirmation. -async function confirm(question: string): Promise { - const answer = await rl.question(`${question} (y/N) `); - return answer.toLowerCase() === "y"; -} +/* ************************************************************************ */ +/* File system helpers */ +/* ************************************************************************ */ -// Removes a path (file or directory). -async function remove(fileOrDirectoryPath: string) { +// Removes a path (file or directory), silently ignoring missing files. +async function remove(rootDir: string, fileOrDirectoryPath: string) { try { await fs.remove(path.join(rootDir, fileOrDirectoryPath)); console.info(`- Removed: ${fileOrDirectoryPath}`); @@ -34,6 +23,7 @@ async function remove(fileOrDirectoryPath: string) { // Updates a file's content if it exists. async function updateFile( + rootDir: string, filePath: string, replacer: (content: string) => string, ) { @@ -54,98 +44,180 @@ async function updateFile( } } -// --- Purge Logic --- +/* ************************************************************************ */ +/* Purge logic */ +/* ************************************************************************ */ // Removes all files and code related to the 'item' module. -async function purgeItems() { +async function purgeItems(rootDir: string) { console.info("\nPurging 'item' module..."); + // Remove item module files and related React components. - await remove("src/express/modules/item"); - await remove("src/react/components/item"); - await remove("tests/api/items.test.ts"); - await remove("tests/react/item.test.tsx"); + await remove(rootDir, "src/express/modules/item"); + await remove(rootDir, "src/react/components/item"); + await remove(rootDir, "tests/react/item.test.tsx"); - // Remove item routes and imports from Express and React. - await updateFile("src/express/routes.ts", (content) => + // Remove item routes from Express. + await updateFile(rootDir, "src/express/routes.ts", (content) => content.replace(`await importAndUse("./modules/item/itemRoutes");\n`, ""), ); - await updateFile("src/react/routes.tsx", (content) => + // Remove item routes and import from React. + await updateFile(rootDir, "src/react/routes.tsx", (content) => content - .replace(`import { itemRoutes } from "./components/item";\n`, "") + .replace(`import { itemRoutes } from "./components/item/index";\n`, "") .replace(` ...itemRoutes,\n`, ""), ); - // Remove item table and inserts from schema, seeder and types. - await updateFile("src/database/schema.sql", (content) => { - const itemTableRegex = /create table item[\s\S]*?;\n\n/m; + // Remove item table from schema. + await updateFile(rootDir, "src/database/schema.sql", (content) => { + const itemTableRegex = /create table item[\s\S]*?;\n\n?/m; return content.replace(itemTableRegex, ""); }); - await updateFile("src/database/seeder.sql", (content) => { + // Remove item inserts from seeder. + await updateFile(rootDir, "src/database/seeder.sql", (content) => { const itemInsertRegex = /insert into item[\s\S]*?;\n/m; return content.replace(itemInsertRegex, ""); }); - await updateFile("src/types/index.d.ts", (content) => - content.replace(/type Item = {[\s\S]*?};\n\n/m, ""), + // Remove Item type. + await updateFile(rootDir, "src/types/index.d.ts", (content) => + content.replace(/type Item = \{[\s\S]*?\};\n\n?/m, ""), ); // Remove item link from NavBar. - await updateFile("src/react/components/NavBar.tsx", (content) => - content.replace(` {link("/items", "Items")}\n`, ""), + await updateFile(rootDir, "src/react/components/NavBar.tsx", (content) => + content.replace(` {link("/items", "Items")}\n`, ""), ); } -// Remove all files and code related to the 'auth' and 'user' modules. -async function purgeAuth() { +// Removes all files and code related to the 'auth' and 'user' modules. +async function purgeAuth(rootDir: string) { console.info("\nPurging 'auth' and 'user' modules..."); + // Remove auth and user module files and related React components. - await remove("src/express/modules/auth"); - await remove("src/express/modules/user"); - await remove("src/react/components/auth"); - await remove("tests/api/auth.test.ts"); - await remove("tests/api/users.test.ts"); - await remove("tests/react/auth.test.tsx"); - - // Remove auth/user routes and imports from Express. - await updateFile("src/express/routes.ts", (content) => + await remove(rootDir, "src/express/modules/auth"); + await remove(rootDir, "src/express/modules/user"); + await remove(rootDir, "src/react/components/auth"); + await remove(rootDir, "tests/react/auth.test.tsx"); + + // Remove auth/user routes from Express. + await updateFile(rootDir, "src/express/routes.ts", (content) => content .replace(`await importAndUse("./modules/auth/authRoutes");\n`, "") .replace(`await importAndUse("./modules/user/userRoutes");\n`, ""), ); - // Remove user table and inserts from schema, seeder and types. - await updateFile("src/database/schema.sql", (content) => { - const userTableRegex = /create table user[\s\S]*?;\n\n/m; - return content.replace(userTableRegex, ""); + // Remove user and magic_link_token tables from schema. + await updateFile(rootDir, "src/database/schema.sql", (content) => { + const userTableRegex = /create table user[\s\S]*?;\n\n?/m; + const magicLinkTableRegex = /create table magic_link_token[\s\S]*?;\n\n?/m; + return content.replace(userTableRegex, "").replace(magicLinkTableRegex, ""); }); - await updateFile("src/database/seeder.sql", (content) => { - const userInsertRegex = /insert into user[\s\S]*?;\n\n/m; + // Remove user inserts from seeder. + await updateFile(rootDir, "src/database/seeder.sql", (content) => { + const userInsertRegex = /insert into user[\s\S]*?;\n\n?/m; return content.replace(userInsertRegex, ""); }); - await updateFile("src/types/index.d.ts", (content) => - content.replace(/type User = {[\s\S]*?};\n\n/m, ""), + // Remove User and MagicLinkToken types. + await updateFile(rootDir, "src/types/index.d.ts", (content) => + content + .replace(/type User = \{[\s\S]*?\};\n\n?/m, "") + .replace(/type MagicLinkToken = \{[\s\S]*?\};\n\n?/m, ""), + ); + + // Remove auth imports, loader, and auth routes from routes.tsx. + await updateFile(rootDir, "src/react/routes.tsx", (content) => + content + // Remove auth-related imports + .replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "") + .replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "") + .replace( + `import { AuthProvider } from "./components/auth/AuthContext";\n`, + "", + ) + // Simplify RouteObject import (remove useLoaderData) + .replace( + `import { type RouteObject, useLoaderData } from "react-router";`, + `import type { RouteObject } from "react-router";`, + ) + // Remove AuthProvider wrapper and useLoaderData usage + .replace( + /Component: \(\) => \{[\s\S]*?\},\n/m, + `Component: () => {\n return (\n \n \n \n );\n },\n`, + ) + // Remove the loader + .replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "") + // Remove logout and verify routes + .replace( + / {6}\{\n {8}path: "logout",\n {8}element: ,\n {6}\},\n/m, + "", + ) + .replace( + / {6}\{\n {8}path: "verify",\n {8}element: ,\n {6}\},\n/m, + "", + ), + ); + + // Remove auth-related code from Layout.tsx. + await updateFile(rootDir, "src/react/components/Layout.tsx", (content) => + content + // Remove auth imports + .replace( + `import { Outlet, useLocation } from "react-router";`, + `import { Outlet } from "react-router";`, + ) + .replace(`import { useAuth } from "./auth/AuthContext";\n`, "") + .replace(`import MagicLinkForm from "./auth/MagicLinkForm";\n`, "") + // Remove auth hooks + .replace(` const { check } = useAuth();\n`, "") + .replace(` const location = useLocation();\n\n`, "") + // Replace conditional rendering with simple Outlet + .replace( + ` {check() || location.pathname === "/verify" ? (\n \n ) : (\n \n )}`, + ` `, + ), ); - // Remove AuthProvider and related imports from Layout. - await updateFile("src/react/components/Layout.tsx", (content) => + // Remove auth-related code from NavBar.tsx. + await updateFile(rootDir, "src/react/components/NavBar.tsx", (content) => content - .replace(`import { AuthProvider } from "./auth/AuthContext";\n`, "") - .replace(`import AuthForm from "./auth/AuthForm";\n`, "") - .replace(/([\s\S]*)<\/AuthProvider>/m, "<>$1"), + .replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "") + .replace(` const { check } = useAuth();\n`, "") + // After purgeItems, only the logout link remains in the auth block. + // Remove the whole conditional block. + .replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, ""), ); } -// Entry point: parse arguments, confirm, and run purge. -async function main() { - const [, , keepAuth, ...unexpected] = process.argv; +/* ************************************************************************ */ +/* Entry point */ +/* ************************************************************************ */ - if ((keepAuth && keepAuth !== "--keep-auth") || unexpected.length > 0) { - console.error("Usage: npm run make:purge [-- --keep-auth]"); - process.exit(1); +export async function main( + argv: string[] = process.argv, + rootDirOverride?: string, +) { + const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); + + const args = argv.slice(2); + + const keepAuth = args.includes("--keep-auth"); + const noInteraction = + args.includes("--no-interaction") || args.includes("-n"); + + const expectedArgs = [ + ...(keepAuth ? ["--keep-auth"] : []), + ...(noInteraction ? ["--no-interaction"] : []), + ]; + + if (args.length !== expectedArgs.length) { + throw new Error( + "Usage: npm run make:purge [-- --keep-auth] [--no-interaction|-n]", + ); } console.info( @@ -160,19 +232,30 @@ async function main() { console.info("All boilerplate modules (item, user, auth) will be removed."); } - const proceed = await confirm( - "Are you sure you want to continue? This action cannot be undone.", - ); - - if (!proceed) { - console.info("\nPurge operation cancelled."); - return; + if (!noInteraction) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question( + "Are you sure you want to continue? This action cannot be undone. (y/N) ", + ); + + if (answer.toLowerCase() !== "y") { + console.info("\nPurge operation cancelled."); + return; + } + } finally { + rl.close(); + } } - await purgeItems(); + await purgeItems(rootDir); if (!keepAuth) { - await purgeAuth(); + await purgeAuth(rootDir); } console.info("\nPurge complete! ✨"); @@ -181,11 +264,10 @@ async function main() { ); } -main() - .catch((err) => { - console.error("An unexpected error occurred:", err); +/* v8 ignore next 6 */ +if (process.env.NODE_ENV !== "test") { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); process.exit(1); - }) - .finally(() => { - rl.close(); }); +} diff --git a/package-lock.json b/package-lock.json index 15f283a..de24ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "starter", - "version": "2026.04.26", + "version": "2026.04.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "starter", - "version": "2026.04.26", + "version": "2026.04.27", "dependencies": { "compression": "^1.8.1", "cookie-parser": "^1.4.7", @@ -37,7 +37,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.5", "fs-extra": "^11.3.4", - "jsdom": "^29.0.2", + "jsdom": "^29.1.0", "pluralize": "^8.0.0", "supertest": "^7.2.2", "tsx": "^4.21.0", @@ -64,9 +64,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", - "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2332,13 +2332,13 @@ } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -3020,28 +3020,28 @@ "peer": true }, "node_modules/jsdom": { - "version": "29.0.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", - "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", + "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.5", - "@asamuzakjp/dom-selector": "^7.0.6", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.5", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -3666,13 +3666,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" diff --git a/package.json b/package.json index a73071a..d5defde 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "starter", - "version": "2026.04.26", + "version": "2026.04.27", "main": "server.ts", "scripts": { "prepare": "git config core.hooksPath .git-hooks || true", @@ -36,7 +36,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.5", "fs-extra": "^11.3.4", - "jsdom": "^29.0.2", + "jsdom": "^29.1.0", "pluralize": "^8.0.0", "supertest": "^7.2.2", "tsx": "^4.21.0", diff --git a/server.ts b/server.ts index 25fbccd..8e40a1b 100644 --- a/server.ts +++ b/server.ts @@ -54,6 +54,8 @@ 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, () => { @@ -121,8 +123,6 @@ export async function createServer() { /* ****************************************************************** */ const getTemplateAndRender = async (url: string) => { - const indexHtml = readIndexHtml(); - // Production mode: // SSR bundle is prebuilt and loaded from dist/ if (maybeVite == null) { @@ -182,6 +182,10 @@ export async function createServer() { /* Error handling */ /* ********************************************************************** */ + /* + Error logging middleware: + Logs errors for debugging, then passes them to the error response handler. + */ const logErrors: ErrorRequestHandler = (err, req, _res, next) => { console.error(err); console.error("on req:", req.method, req.path); @@ -189,7 +193,22 @@ export async function createServer() { next(err); }; + /* + Final error handler: + Sends a structured JSON response instead of Express's default HTML page. + Stack traces are hidden in production to avoid leaking implementation details. + */ + const sendErrors: ErrorRequestHandler = (err, _req, res, _next) => { + const status = err.status ?? err.statusCode ?? 500; + + res.status(status).json({ + message: err.message ?? "Internal Server Error", + ...(isProduction ? {} : { stack: err.stack }), + }); + }; + app.use(logErrors); + app.use(sendErrors); return app; } diff --git a/src/react/components/auth/MagicLinkForm.tsx b/src/react/components/auth/MagicLinkForm.tsx index 40b178e..4e03397 100644 --- a/src/react/components/auth/MagicLinkForm.tsx +++ b/src/react/components/auth/MagicLinkForm.tsx @@ -48,7 +48,7 @@ function MagicLinkForm() { type="email" name="email" defaultValue="" - placeholder="ton.adresse@mail.com" + placeholder="your.address@mail.com" required /> diff --git a/src/react/components/auth/VerifyPage.tsx b/src/react/components/auth/VerifyPage.tsx index 9952bc3..861780d 100644 --- a/src/react/components/auth/VerifyPage.tsx +++ b/src/react/components/auth/VerifyPage.tsx @@ -46,7 +46,7 @@ function VerifyPage() { This login link is no longer valid. It may have expired or already been used.

- Request a new link + Request a new link ); } diff --git a/src/react/components/item/ItemForm.tsx b/src/react/components/item/ItemForm.tsx index d6355c6..4a92664 100644 --- a/src/react/components/item/ItemForm.tsx +++ b/src/react/components/item/ItemForm.tsx @@ -17,7 +17,7 @@ import { type PropsWithChildren, useId } from "react"; import { z } from "zod"; const itemSchema = z.object({ - title: z.string().min(1, "Le titre est requis"), + title: z.string().min(1, "Title is required"), }); /* diff --git a/tests/bin/database-sync.test.ts b/tests/bin/database-sync.test.ts new file mode 100644 index 0000000..dc4ed40 --- /dev/null +++ b/tests/bin/database-sync.test.ts @@ -0,0 +1,131 @@ +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import fs from "fs-extra"; + +import { main } from "../../bin/database-sync"; + +const sqlitePath = path.join( + import.meta.dirname, + "../../src/database/data/database.sqlite", +); + +const runMainWith = async (args: string[]) => { + await main(args); + + // Verify the database was created + expect(await fs.pathExists(sqlitePath)).toBe(true); +}; + +const check = (predicate: (db: DatabaseSync) => void) => { + const db = new DatabaseSync(sqlitePath); + + try { + predicate(db); + } finally { + db.close(); + } +}; + +const checkSchema = () => { + check((db) => { + const tables = db + .prepare( + "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", + ) + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + + expect(tableNames).toContain("user"); + expect(tableNames).toContain("item"); + expect(tableNames).toContain("magic_link_token"); + }); +}; + +describe("database-sync.ts", () => { + let consoleSpy: ReturnType; + + let hadDatabase: boolean; + let backupPath: string; + + beforeAll(async () => { + // Save a backup of the current database if it exists + hadDatabase = await fs.pathExists(sqlitePath); + + if (hadDatabase) { + backupPath = `${sqlitePath}.bak`; + await fs.copy(sqlitePath, backupPath); + } + }); + + beforeEach(() => { + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + afterAll(async () => { + // Restore the original database + if (hadDatabase) { + await fs.move(backupPath, sqlitePath, { overwrite: true }); + } + }); + + it("fails when given unexpected arguments", async () => { + await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow( + /Usage/, + ); + }); + + it("cancels sync when user answers no interactively", async () => { + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await main(["node", "script"]); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); + }); + + it("syncs the database with schema in interactive mode", async () => { + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "y", + close: vi.fn(), + }); + + await runMainWith(["node", "script"]); + + checkSchema(); + }); + + it("syncs the database with schema in non-interactive mode", async () => { + await runMainWith(["node", "script", "-n"]); + + checkSchema(); + }); + + it("seeds the database when --use-seeder is passed", async () => { + await runMainWith(["node", "script", "--use-seeder", "-n"]); + + checkSchema(); + + check((db) => { + const users = db.prepare("select * from user").all(); + + expect(users.length).toBeGreaterThan(0); + + const items = db.prepare("select * from item").all(); + + expect(items.length).toBeGreaterThan(0); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Seeded using/), + ); + }); +}); diff --git a/tests/bin/make-clone.test.ts b/tests/bin/make-clone.test.ts index 871dd1c..b1aeedf 100644 --- a/tests/bin/make-clone.test.ts +++ b/tests/bin/make-clone.test.ts @@ -19,17 +19,13 @@ describe("make-clone.ts", () => { }); it("fails when missing arguments", async () => { - await expect(main(["node", "script"])).rejects.toThrow( - /Usage: npm run make:clone -- /i, - ); + await expect(main(["node", "script"])).rejects.toThrow(/Usage/i); }); it("fails with invalid JavaScript identifiers for oldName", async () => { const src = path.join(tmpDir, "src"); const dest = path.join(tmpDir, "dest"); - await fs.mkdir(src); - await expect( main(["node", "script", src, dest, "123cherry", "berry"]), ).rejects.toThrow(/is not a valid identifier/); @@ -56,6 +52,7 @@ describe("make-clone.ts", () => { it("fails when source is neither a file nor a directory", async () => { const src = path.join(tmpDir, "special"); const dest = path.join(tmpDir, "dest"); + await fs.mkdir(src); const originalStat = fs.stat; diff --git a/tests/bin/make-purge.test.ts b/tests/bin/make-purge.test.ts new file mode 100644 index 0000000..fc68cfe --- /dev/null +++ b/tests/bin/make-purge.test.ts @@ -0,0 +1,407 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "fs-extra"; + +import { main } from "../../bin/make-purge"; + +const projectRoot = path.join(import.meta.dirname, "../.."); + +/** + * Creates a project structure by copying the real source files. + * This ensures our regex replacements are tested against the actual codebase, + * 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, "tests"), path.join(rootDir, "tests")); +} + +const isAlreadyPurged = !fs.existsSync( + path.join(projectRoot, "src/express/modules/item"), +); + +describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { + let tmpDir: string; + let consoleSpy: ReturnType; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "make-purge-test-")); + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + consoleSpy.mockRestore(); + }); + + it("fails when given unexpected arguments", async () => { + await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow( + /Usage/, + ); + }); + + it("fails when given extra arguments", async () => { + await expect( + main(["node", "script", "--keep-auth", "--extra"]), + ).rejects.toThrow(/Usage/); + }); + + describe("purge logic", () => { + it("runs full purge (items, auth) in non-interactive mode", async () => { + await scaffoldProject(tmpDir); + + await main(["node", "script", "-n"], tmpDir); + + // Verify item routes are gone + const expressRoutes = await fs.readFile( + path.join(tmpDir, "src/express/routes.ts"), + "utf8", + ); + expect(expressRoutes).not.toContain("itemRoutes"); + expect(expressRoutes).not.toContain("authRoutes"); + expect(expressRoutes).not.toContain("userRoutes"); + + // Verify files were removed + expect( + await fs.pathExists(path.join(tmpDir, "src/express/modules/item")), + ).toBe(false); + expect( + await fs.pathExists(path.join(tmpDir, "src/express/modules/auth")), + ).toBe(false); + }); + + it("runs purge for items only with --keep-auth", async () => { + await scaffoldProject(tmpDir); + + await main(["node", "script", "-n", "--keep-auth"], tmpDir); + + // Verify item routes are gone, but auth routes remain + const expressRoutes = await fs.readFile( + path.join(tmpDir, "src/express/routes.ts"), + "utf8", + ); + expect(expressRoutes).not.toContain("itemRoutes"); + expect(expressRoutes).toContain("authRoutes"); + + // Verify files were removed/kept + expect( + await fs.pathExists(path.join(tmpDir, "src/express/modules/item")), + ).toBe(false); + expect( + await fs.pathExists(path.join(tmpDir, "src/express/modules/auth")), + ).toBe(true); + }); + + it("cancels purge when user answers no interactively", async () => { + await scaffoldProject(tmpDir); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await main(["node", "script"], tmpDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/cancelled/), + ); + expect( + await fs.pathExists(path.join(tmpDir, "src/express/modules/item")), + ).toBe(true); + }); + + it("proceeds with purge when user answers yes interactively", async () => { + await scaffoldProject(tmpDir); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "y", + close: vi.fn(), + }); + + await main(["node", "script", "--keep-auth"], tmpDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Purge complete/), + ); + expect( + await fs.pathExists(path.join(tmpDir, "src/express/modules/item")), + ).toBe(false); + }); + }); + + describe("purgeItems", () => { + it("removes item table from schema.sql", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/database/schema.sql"), + "utf8", + ); + + const itemTableRegex = /create table item[\s\S]*?;\n\n?/m; + const result = content.replace(itemTableRegex, ""); + + expect(result).not.toContain("create table item"); + expect(result).toContain("create table user"); + expect(result).toContain("create table magic_link_token"); + }); + + it("removes item inserts from seeder.sql", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/database/seeder.sql"), + "utf8", + ); + + const itemInsertRegex = /insert into item[\s\S]*?;\n/m; + const result = content.replace(itemInsertRegex, ""); + + expect(result).not.toContain("insert into item"); + expect(result).toContain("insert into user"); + }); + + it("removes Item type from index.d.ts", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/types/index.d.ts"), + "utf8", + ); + + const result = content.replace(/type Item = \{[\s\S]*?\};\n\n?/m, ""); + + expect(result).not.toContain("type Item"); + expect(result).toContain("type User"); + expect(result).toContain("type MagicLinkToken"); + }); + + it("removes item link from NavBar.tsx", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/react/components/NavBar.tsx"), + "utf8", + ); + + const result = content.replace( + ` {link("/items", "Items")}\n`, + "", + ); + + expect(result).not.toContain("/items"); + expect(result).toContain("/logout"); + expect(result).toContain("Home"); + }); + + it("removes item routes from routes.tsx", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/react/routes.tsx"), + "utf8", + ); + + const result = content + .replace(`import { itemRoutes } from "./components/item/index";\n`, "") + .replace(` ...itemRoutes,\n`, ""); + + expect(result).not.toContain("itemRoutes"); + expect(result).toContain("LogoutForm"); + }); + + it("removes item route from express routes.ts", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/express/routes.ts"), + "utf8", + ); + + const result = content.replace( + `await importAndUse("./modules/item/itemRoutes");\n`, + "", + ); + + expect(result).not.toContain("itemRoutes"); + expect(result).toContain("authRoutes"); + expect(result).toContain("userRoutes"); + }); + }); + + describe("purgeAuth", () => { + it("removes user and magic_link_token tables from schema.sql", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/database/schema.sql"), + "utf8", + ); + + const userTableRegex = /create table user[\s\S]*?;\n\n?/m; + const magicLinkTableRegex = + /create table magic_link_token[\s\S]*?;\n\n?/m; + const result = content + .replace(userTableRegex, "") + .replace(magicLinkTableRegex, ""); + + expect(result).not.toContain("create table user"); + expect(result).not.toContain("create table magic_link_token"); + expect(result).toContain("create table item"); + }); + + it("removes user inserts from seeder.sql", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/database/seeder.sql"), + "utf8", + ); + + const userInsertRegex = /insert into user[\s\S]*?;\n\n?/m; + const result = content.replace(userInsertRegex, ""); + + expect(result).not.toContain("insert into user"); + expect(result).toContain("insert into item"); + }); + + it("removes User and MagicLinkToken types from index.d.ts", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/types/index.d.ts"), + "utf8", + ); + + const result = content + .replace(/type User = \{[\s\S]*?\};\n\n?/m, "") + .replace(/type MagicLinkToken = \{[\s\S]*?\};\n\n?/m, ""); + + expect(result).not.toContain("type User"); + expect(result).not.toContain("type MagicLinkToken"); + expect(result).toContain("type Item"); + }); + + it("removes auth imports and loader from routes.tsx", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/react/routes.tsx"), + "utf8", + ); + + const result = content + .replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "") + .replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "") + .replace( + `import { AuthProvider } from "./components/auth/AuthContext";\n`, + "", + ) + .replace( + `import { type RouteObject, useLoaderData } from "react-router";`, + `import type { RouteObject } from "react-router";`, + ) + .replace( + /Component: \(\) => \{[\s\S]*?\},\n/m, + `Component: () => {\n return (\n \n \n \n );\n },\n`, + ) + .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, + "", + ) + .replace( + / {6}\{\n {8}path: "verify",\n {8}element: ,\n {6}\},\n/m, + "", + ); + + expect(result).not.toContain("LogoutForm"); + 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: "verify"'); + expect(result).toContain("DataRefreshProvider"); + expect(result).toContain("Layout"); + }); + + it("removes auth code from Layout.tsx", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/react/components/Layout.tsx"), + "utf8", + ); + + const result = content + .replace( + `import { Outlet, useLocation } from "react-router";`, + `import { Outlet } from "react-router";`, + ) + .replace(`import { useAuth } from "./auth/AuthContext";\n`, "") + .replace(`import MagicLinkForm from "./auth/MagicLinkForm";\n`, "") + .replace(` const { check } = useAuth();\n`, "") + .replace(` const location = useLocation();\n\n`, "") + .replace( + ` {check() || location.pathname === "/verify" ? (\n \n ) : (\n \n )}`, + ` `, + ); + + expect(result).not.toContain("useAuth"); + expect(result).not.toContain("MagicLinkForm"); + expect(result).not.toContain("useLocation"); + expect(result).not.toContain("check()"); + expect(result).toContain(""); + expect(result).toContain("NavBar"); + }); + + it("removes auth code from NavBar.tsx (after purgeItems)", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/react/components/NavBar.tsx"), + "utf8", + ); + + // First simulate purgeItems removing the items link + const afterItems = content.replace( + ` {link("/items", "Items")}\n`, + "", + ); + + // Then simulate purgeAuth + const result = afterItems + .replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "") + .replace(` const { check } = useAuth();\n`, "") + .replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, ""); + + expect(result).not.toContain("useAuth"); + expect(result).not.toContain("check()"); + expect(result).not.toContain("/items"); + expect(result).not.toContain("/logout"); + expect(result).toContain("Home"); + }); + + it("removes auth/user routes from express routes.ts", async () => { + await scaffoldProject(tmpDir); + + const content = await fs.readFile( + path.join(tmpDir, "src/express/routes.ts"), + "utf8", + ); + + const result = content + .replace(`await importAndUse("./modules/auth/authRoutes");\n`, "") + .replace(`await importAndUse("./modules/user/userRoutes");\n`, ""); + + expect(result).not.toContain("authRoutes"); + expect(result).not.toContain("userRoutes"); + expect(result).toContain("itemRoutes"); + }); + }); +});