From 5355522971682363271f60539a492720941574ed Mon Sep 17 00:00:00 2001 From: rocambille Date: Sat, 4 Jul 2026 19:24:08 +0200 Subject: [PATCH 1/4] refactored database schema usage to allow migrations --- AGENTS.md | 13 +- README.fr-FR.md | 2 +- README.md | 2 +- package.json | 5 +- scripts/database-helpers.ts | 109 +++++++++++ scripts/database-migrate.ts | 168 +++++++++++++++++ scripts/database-reset.ts | 87 +++++++++ scripts/database-sync.ts | 122 ------------ scripts/make-clone.ts | 4 +- tests/express/test-utils.ts | 16 +- tests/scripts/database-migrate.test.ts | 247 +++++++++++++++++++++++++ tests/scripts/database-reset.test.ts | 164 ++++++++++++++++ tests/scripts/database-sync.test.ts | 162 ---------------- 13 files changed, 796 insertions(+), 305 deletions(-) create mode 100644 scripts/database-helpers.ts create mode 100644 scripts/database-migrate.ts create mode 100644 scripts/database-reset.ts delete mode 100644 scripts/database-sync.ts create mode 100644 tests/scripts/database-migrate.test.ts create mode 100644 tests/scripts/database-reset.test.ts delete mode 100644 tests/scripts/database-sync.test.ts diff --git a/AGENTS.md b/AGENTS.md index efca474..bb45242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ │ ├── entry-server.tsx # SSR rendering (renderToPipeableStream) │ ├── database/ │ │ ├── schema.sql # SQLite schema — source of truth for DB structure +│ │ ├── migrations/ # Forward-only migration scripts (production) │ │ └── seeder.sql # Test/seed data │ ├── express/ │ │ ├── routes.ts # Registers all Express modules via importAndUse() @@ -60,19 +61,17 @@ ```bash npm install cp .env.sample .env -npm run database:sync # Load schema + seed data into SQLite +npm run database:reset # Drop all, replay schema + migrations + seeder npm run dev # Start dev server (Express + Vite together on port 5173) ``` ### Database ```bash -npm run database:schema:load # Apply schema.sql to the SQLite file -npm run database:seeder:load # Load seeder.sql test data -npm run database:sync # Both above — resets DB to a clean state -npm run database:sync -- -n # Non-interactive (CI/CD — skips confirmation prompt) -npm run database:schema:load -- -n # Same, schema only -npm run database:seeder:load -- -n # Same, seeder only +npm run database:reset # Drop all, replay schema + migrations + seeder +npm run database:reset -- -n # Non-interactive (CI/CD — skips confirmation prompt) +npm run database:migrate # Apply un-applied migrations (production) +npm run database:migrate -- -n # Non-interactive (CI/CD) ``` > SQLite requires NO Docker, NO connection string, NO async setup. The DB file is created on the fly. diff --git a/README.fr-FR.md b/README.fr-FR.md index a4223c9..b2d98bc 100644 --- a/README.fr-FR.md +++ b/README.fr-FR.md @@ -50,7 +50,7 @@ cd mon-projet # 2. Installer les dépendances et initialiser la base de données npm install cp .env.sample .env -npm run database:sync +npm run database:reset # 3. Lancer l'application npm run dev diff --git a/README.md b/README.md index 3f875e7..efdf0fd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ cd my-prototype # 2. Install dependencies and initialize the database npm install cp .env.sample .env -npm run database:sync +npm run database:reset # 3. Start co-creating npm run dev diff --git a/package.json b/package.json index 426310e..71a6ba2 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,8 @@ "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 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", + "database:migrate": "tsx --env-file=.env scripts/database-migrate", + "database:reset": "tsx --env-file=.env scripts/database-reset", "dev": "tsx watch --include .env --env-file=.env server", "install:check": "vitest run tests/install", "make:clone": "tsx --env-file=.env scripts/make-clone", diff --git a/scripts/database-helpers.ts b/scripts/database-helpers.ts new file mode 100644 index 0000000..816f91e --- /dev/null +++ b/scripts/database-helpers.ts @@ -0,0 +1,109 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; + +/** + * Scans `src/database/` for all SQL files in the correct execution order: + * 1. `schema.sql` (always first) + * 2. migration files in `migrations/` (alphabetical order) + * 3. `seeder.sql` (always last) + * + * Missing files or directories are silently skipped. + */ +export function scanDatabaseFiles(rootDir: string): string[] { + const databaseDir = path.join(rootDir, "src/database"); + + const files: string[] = []; + + // 1. schema.sql first + const schemaFile = path.join(databaseDir, "schema.sql"); + if (fs.existsSync(schemaFile)) { + files.push(schemaFile); + } + + // 2. migration files in alphabetical order + const migrationsDir = path.join(databaseDir, "migrations"); + if (fs.existsSync(migrationsDir)) { + const migrationFiles = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort() + .map((f) => path.join(migrationsDir, f)); + + files.push(...migrationFiles); + } + + // 3. seeder.sql last + const seederFile = path.join(databaseDir, "seeder.sql"); + if (fs.existsSync(seederFile)) { + files.push(seederFile); + } + + return files; +} + +/** + * Same as scanDatabaseFiles but excludes seeder.sql. + * Used by `database:migrate` (production) and test infrastructure. + */ +export function scanMigrationFiles(rootDir: string): string[] { + return scanDatabaseFiles(rootDir).filter( + (f) => path.basename(f) !== "seeder.sql", + ); +} + +/** + * Computes the SHA-256 checksum of a file's content. + */ +export function computeChecksum(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex"); +} + +/** + * Drops all user-created tables from the database. + * Temporarily disables foreign keys to avoid cascade errors during drop. + */ +export function dropAllTables(database: DatabaseSync): void { + const existingTables = database + .prepare( + "select name from sqlite_schema where type ='table' and name not like 'sqlite_%'", + ) + .all() as { name: string }[]; + + // Prevent errors because of cascade deletion + database.exec("PRAGMA foreign_keys = OFF"); + + for (const table of existingTables) { + database.exec(`drop table "${table.name}"`); + } + + // Re-enable cascade deletion + database.exec("PRAGMA foreign_keys = ON"); +} + +/** + * Ensures the _migrations tracking table exists. + */ +export function ensureMigrationsTable(database: DatabaseSync): void { + database.exec(` + create table if not exists _migrations ( + filename text primary key, + checksum text not null, + applied_at datetime default current_timestamp + ); + `); +} + +/** + * Records an executed SQL file in the _migrations table. + */ +export function recordMigration( + database: DatabaseSync, + filename: string, + checksum: string, +): void { + database + .prepare("insert into _migrations (filename, checksum) values (?, ?)") + .run(filename, checksum); +} diff --git a/scripts/database-migrate.ts b/scripts/database-migrate.ts new file mode 100644 index 0000000..b03c6eb --- /dev/null +++ b/scripts/database-migrate.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline/promises"; +import database from "../src/database"; +import { + computeChecksum, + ensureMigrationsTable, + recordMigration, + scanMigrationFiles, +} from "./database-helpers"; + +export async function main( + argv: string[] = process.argv, + rootDirOverride?: string, +) { + const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); + + const databaseFile = database.location() ?? ":memory:"; + + const args = argv.slice(2); + + const noInteraction = + args.includes("--no-interaction") || args.includes("-n"); + + const expectedArgs = noInteraction ? ["--no-interaction"] : []; + + if (args.length !== expectedArgs.length) { + throw new Error("Usage: database-migrate [--no-interaction|-n]"); + } + + // Back up the database file (temporary: deleted after migration) + const backupFile = `${databaseFile}.bak`; + const isFileDatabase = databaseFile !== ":memory:"; + + if (isFileDatabase) { + fs.copyFileSync(databaseFile, backupFile); + console.info(`\nBackup created: '${path.normalize(backupFile)}'`); + } + + // Ensure the _migrations tracking table exists + ensureMigrationsTable(database); + + // Scan all SQL files (schema + migrations, excluding seeder) + const files = scanMigrationFiles(rootDir); + + // Check which files have already been applied + const applied = new Map(); + const rows = database + .prepare("select filename, checksum from _migrations") + .all() as { filename: string; checksum: string }[]; + + for (const row of rows) { + applied.set(row.filename, row.checksum); + } + + // Determine which files need to be applied + const pending: { file: string; sql: string }[] = []; + + for (const file of files) { + const sql = fs.readFileSync(file, "utf8"); + const filename = path.basename(file); + const currentChecksum = computeChecksum(sql); + + if (applied.has(filename)) { + const storedChecksum = applied.get(filename) as string; + + if (storedChecksum !== currentChecksum) { + console.warn( + `\n⚠️ '${filename}' was modified since it was applied. These changes will NOT take effect. Revert your change or write a new migration script.`, + ); + } + } else { + pending.push({ file, sql }); + } + } + + if (pending.length === 0) { + console.info("\nNothing to migrate. Database is up to date. ✅"); + + // Clean up backup + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.unlinkSync(backupFile); + } + + return; + } + + // Confirm before applying + console.info(`\n${pending.length} file(s) to apply:`); + for (const { file } of pending) { + console.info(` - ${path.basename(file)}`); + } + + if (!noInteraction) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question("\nApply these migrations? (y/N) "); + + if (answer.toLowerCase() !== "y") { + console.info("\nMigration cancelled."); + + // Clean up backup + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.unlinkSync(backupFile); + } + + return; + } + } finally { + rl.close(); + } + } + + // Apply pending files inside a transaction + try { + database.exec("BEGIN"); + + for (const { file, sql } of pending) { + database.exec(sql); + + const filename = path.basename(file); + const checksum = computeChecksum(sql); + recordMigration(database, filename, checksum); + + console.info( + `\n'${filename}' applied to '${path.normalize(databaseFile)}' ✅`, + ); + } + + database.exec("COMMIT"); + + console.info("\nMigration complete! ✅"); + } catch (error) { + // Rollback the transaction + try { + database.exec("ROLLBACK"); + } catch { + // ROLLBACK may fail if the transaction was already rolled back + } + + // Restore from backup + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.copyFileSync(backupFile, databaseFile); + console.info( + `\nDatabase restored from backup: '${path.normalize(backupFile)}'`, + ); + } + + throw error; + } finally { + // Always clean up the backup file + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.unlinkSync(backupFile); + } + } +} + +/* v8 ignore next 6 */ +if (process.env.NODE_ENV !== "test") { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/database-reset.ts b/scripts/database-reset.ts new file mode 100644 index 0000000..e5aede4 --- /dev/null +++ b/scripts/database-reset.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline/promises"; +import database from "../src/database"; +import { + computeChecksum, + dropAllTables, + ensureMigrationsTable, + recordMigration, + scanDatabaseFiles, +} from "./database-helpers"; + +export async function main( + argv: string[] = process.argv, + rootDirOverride?: string, +) { + const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); + + const databaseFile = database.location() ?? ":memory:"; + + const args = argv.slice(2); + + const noInteraction = + args.includes("--no-interaction") || args.includes("-n"); + + const expectedArgs = noInteraction ? ["--no-interaction"] : []; + + if (args.length !== expectedArgs.length) { + throw new Error("Usage: database-reset [--no-interaction|-n]"); + } + + console.info( + `This script will drop existing data in '${path.normalize(databaseFile)}'.`, + ); + + 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("\nReset operation cancelled."); + return; + } + } finally { + rl.close(); + } + } + + // Drop all existing tables (including _migrations) + dropAllTables(database); + + // Create a fresh _migrations tracking table + ensureMigrationsTable(database); + + // Scan and execute all SQL files in order + const files = scanDatabaseFiles(rootDir); + + for (const file of files) { + const sql = fs.readFileSync(file, "utf8"); + + database.exec(sql); + + // Record the execution in _migrations + const filename = path.basename(file); + const checksum = computeChecksum(sql); + recordMigration(database, filename, checksum); + + console.info( + `\n'${path.normalize(file)}' loaded in '${path.normalize(databaseFile)}' ✅`, + ); + } +} + +/* v8 ignore next 6 */ +if (process.env.NODE_ENV !== "test") { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/database-sync.ts b/scripts/database-sync.ts deleted file mode 100644 index 55b157b..0000000 --- a/scripts/database-sync.ts +++ /dev/null @@ -1,122 +0,0 @@ -import path from "node:path"; -import readline from "node:readline/promises"; -import fs from "fs-extra"; -import database from "../src/database"; - -export async function main( - argv: string[] = process.argv, - rootDirOverride?: string, -) { - const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); - - // Locate the schema, seeder and database files - const schemaFile = path.join(rootDir, "src/database/schema.sql"); - const seederFile = path.join(rootDir, "src/database/seeder.sql"); - const databaseFile = database.location() ?? ":memory:"; - - const args = argv.slice(2); - - const target = args.includes("schema") - ? "schema" - : args.includes("seeder") - ? "seeder" - : args.includes("both") - ? "both" - : null; - - const noInteraction = - args.includes("--no-interaction") || args.includes("-n"); - - const expectedArgs = [target, ...(noInteraction ? ["--no-interaction"] : [])]; - - if (target == null || args.length !== expectedArgs.length) { - throw new Error( - "Usage: database-sync [--no-interaction|-n] schema|seeder|both", - ); - } - - console.info( - `This script will drop existing data in '${path.normalize(databaseFile)}'.`, - ); - - 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("\nSync operation cancelled."); - return; - } - } finally { - rl.close(); - } - } - - if (target === "schema" || target === "both") { - // Drop existing tables - const existingTables = database - .prepare( - "select name from sqlite_schema where type ='table' and name not like 'sqlite_%'", - ) - .all() as { name: string }[]; - - // Prevent errors because of cascade deletion - database.exec("PRAGMA foreign_keys = OFF"); - - for (const table of existingTables) { - database.exec(`drop table "${table.name}"`); - } - - // Re-enable cascade deletion - database.exec("PRAGMA foreign_keys = ON"); - - // Read the SQL statements from the schema file - const sql = await fs.readFile(schemaFile, "utf8"); - - // Execute the SQL statements to update the database schema - database.exec(sql); - - console.info( - `\nSchema '${path.normalize(schemaFile)}' loaded in '${path.normalize(databaseFile)}' 🆙`, - ); - } - - if (target === "seeder" || target === "both") { - // truncate existing tables - const existingTables = database - .prepare(` - select name - from sqlite_schema - where type ='table' and name not like 'sqlite_%'`) - .all(); - - for (const table of existingTables) { - database.exec(`delete from "${table.name}"`); - } - - // Read the SQL statements from the seeder file - const sql = await fs.readFile(seederFile, "utf8"); - - // Execute the SQL statements to seed the database - database.exec(sql); - - console.info( - `\nSeeder '${path.normalize(seederFile)}' loaded in '${path.normalize(databaseFile)}' 🌱`, - ); - } -} - -/* v8 ignore next 6 */ -if (process.env.NODE_ENV !== "test") { - main().catch((err) => { - console.error(err instanceof Error ? err.message : err); - process.exit(1); - }); -} diff --git a/scripts/make-clone.ts b/scripts/make-clone.ts index 1e84f0f..e86df25 100644 --- a/scripts/make-clone.ts +++ b/scripts/make-clone.ts @@ -189,9 +189,9 @@ export async function main(argv: string[] = process.argv) { [ ] Register the new table in src/database/schema.sql [ ] (Optional) Add dummy data in src/database/seeder.sql -[ ] Sync database: +[ ] Reset database: - npm run database:sync + npm run database:reset [ ] Define the "${singular}" type in src/types/index.d.ts`); diff --git a/tests/express/test-utils.ts b/tests/express/test-utils.ts index 1c3abdb..e6ce7d2 100644 --- a/tests/express/test-utils.ts +++ b/tests/express/test-utils.ts @@ -25,6 +25,8 @@ vi.mock("../../src/database", () => ({ default: new DatabaseSync(":memory:"), })); +import { scanMigrationFiles } from "../../scripts/database-helpers"; + const mockDatabase = () => { /* drop existing tables */ const existingTables = database @@ -44,14 +46,14 @@ const mockDatabase = () => { /* re-enable cascade deletion */ database.exec("PRAGMA foreign_keys = ON"); - /* load schema */ - const schema = path.join( - import.meta.dirname, - "../../src/database/schema.sql", - ); + /* load schema + migration files */ + const rootDir = path.join(import.meta.dirname, "../.."); + const files = scanMigrationFiles(rootDir); - const schemaSql = fs.readFileSync(schema, "utf8"); - database.exec(schemaSql); + for (const file of files) { + const sql = fs.readFileSync(file, "utf8"); + database.exec(sql); + } /* insert all users */ const insertUser = database.prepare( diff --git a/tests/scripts/database-migrate.test.ts b/tests/scripts/database-migrate.test.ts new file mode 100644 index 0000000..130d816 --- /dev/null +++ b/tests/scripts/database-migrate.test.ts @@ -0,0 +1,247 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { main as migrateMain } from "../../scripts/database-migrate"; +import { main as resetMain } from "../../scripts/database-reset"; +import database from "../../src/database"; + +vi.mock("../../src/database", () => ({ + default: new DatabaseSync(":memory:"), +})); + +describe("database-migrate.ts", () => { + let consoleSpy: ReturnType; + let warnSpy: ReturnType; + let tempRootDir: string; + let tempDatabaseDir: string; + let migrationsDir: string; + + const rootDir = path.join(import.meta.dirname, "../.."); + + beforeAll(async () => { + // Create a unique temporary directory for this test file + tempRootDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "db-migrate-test-"), + ); + tempDatabaseDir = path.join(tempRootDir, "src/database"); + migrationsDir = path.join(tempDatabaseDir, "migrations"); + + await fs.promises.mkdir(tempDatabaseDir, { recursive: true }); + // Copy real schema.sql and seeder.sql + const realDatabaseDir = path.join(rootDir, "src/database"); + await fs.promises.copyFile( + path.join(realDatabaseDir, "schema.sql"), + path.join(tempDatabaseDir, "schema.sql"), + ); + await fs.promises.copyFile( + path.join(realDatabaseDir, "seeder.sql"), + path.join(tempDatabaseDir, "seeder.sql"), + ); + }); + + beforeEach(async () => { + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Start from a clean reset so _migrations is populated + await resetMain(["node", "script", "-n"], tempRootDir); + consoleSpy.mockClear(); + warnSpy.mockClear(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + afterAll(async () => { + // Clean up temporary directory + await fs.promises.rm(tempRootDir, { recursive: true, force: true }); + }); + + it("fails when given unexpected arguments", async () => { + await expect( + migrateMain(["node", "script", "--unknown-flag"], tempRootDir), + ).rejects.toThrow(/usage/i); + }); + + it("reports nothing to migrate when database is up to date", async () => { + await migrateMain(["node", "script", "-n"], tempRootDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); + }); + + it("applies a new migration file", async () => { + // Create a migration that adds a test table + const testFile = path.join(migrationsDir, "0001_add_test_table.sql"); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE test_migrate (id INTEGER PRIMARY KEY);\n", + ); + + try { + await migrateMain(["node", "script", "-n"], tempRootDir); + + // Verify the table was created + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'test_migrate'", + ) + .all() as { name: string }[]; + + expect(tables.length).toBe(1); + + // Verify it was tracked in _migrations + const migration = database + .prepare("select * from _migrations where filename = ?") + .get("0001_add_test_table.sql") as + | { filename: string; checksum: string } + | undefined; + + expect(migration).toBeDefined(); + expect(migration?.checksum.length).toBe(64); + } finally { + await fs.promises.unlink(testFile); + } + }); + + it("skips already-applied files", async () => { + // Create and apply a migration + const testFile = path.join(migrationsDir, "0002_skip_test.sql"); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE skip_test (id INTEGER PRIMARY KEY);\n", + ); + + try { + // Apply it once + await migrateMain(["node", "script", "-n"], tempRootDir); + consoleSpy.mockClear(); + + // Apply again: should report nothing to migrate + await migrateMain(["node", "script", "-n"], tempRootDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); + } finally { + await fs.promises.unlink(testFile); + } + }); + + it("warns when an applied file has been modified", async () => { + // Create and apply a migration + const testFile = path.join(migrationsDir, "0003_checksum_test.sql"); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY);\n", + ); + + try { + // Apply it + await migrateMain(["node", "script", "-n"], tempRootDir); + warnSpy.mockClear(); + + // Modify the file + await fs.promises.writeFile( + testFile, + "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY, name TEXT);\n", + ); + + // Migrate again: should warn about modification + await migrateMain(["node", "script", "-n"], tempRootDir); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/was modified since it was applied/), + ); + } finally { + await fs.promises.unlink(testFile); + } + }); + + it("does not apply seeder.sql", async () => { + // After reset, seeder data exists. Let's clear it and migrate. + database.exec("DELETE FROM item"); + database.exec("DELETE FROM user"); + consoleSpy.mockClear(); + + await migrateMain(["node", "script", "-n"], tempRootDir); + + // Seeder should NOT have been re-applied + const users = database.prepare("select * from user").all(); + expect(users.length).toBe(0); + }); + + it("respects file ordering: schema.sql first, then migrations alphabetically", async () => { + const fileA = path.join(migrationsDir, "aaa_first.sql"); + const fileB = path.join(migrationsDir, "zzz_second.sql"); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile(fileA, "-- first\n"); + await fs.promises.writeFile(fileB, "-- second\n"); + + try { + await migrateMain(["node", "script", "-n"], tempRootDir); + + const migrations = database + .prepare( + "SELECT filename FROM _migrations WHERE filename IN ('aaa_first.sql', 'zzz_second.sql') ORDER BY applied_at", + ) + .all() as { filename: string }[]; + + const filenames = migrations.map((m) => m.filename); + expect(filenames).toEqual(["aaa_first.sql", "zzz_second.sql"]); + } finally { + await fs.promises.unlink(fileA); + await fs.promises.unlink(fileB); + } + }); + + it("rolls back on execution failure", async () => { + const goodFile = path.join(migrationsDir, "0004_good.sql"); + const badFile = path.join(migrationsDir, "0005_bad.sql"); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile( + goodFile, + "CREATE TABLE good_table (id INTEGER PRIMARY KEY);\n", + ); + await fs.promises.writeFile(badFile, "THIS IS NOT VALID SQL;\n"); + + try { + await expect( + migrateMain(["node", "script", "-n"], tempRootDir), + ).rejects.toThrow(); + + // The good table should NOT exist (transaction was rolled back) + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'good_table'", + ) + .all(); + + expect(tables.length).toBe(0); + + // Neither file should be tracked in _migrations + const tracked = database + .prepare( + "select * from _migrations where filename IN ('0004_good.sql', '0005_bad.sql')", + ) + .all(); + + expect(tracked.length).toBe(0); + } finally { + await fs.promises.unlink(goodFile); + await fs.promises.unlink(badFile); + } + }); +}); diff --git a/tests/scripts/database-reset.test.ts b/tests/scripts/database-reset.test.ts new file mode 100644 index 0000000..05fac47 --- /dev/null +++ b/tests/scripts/database-reset.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { main } from "../../scripts/database-reset"; +import database from "../../src/database"; + +vi.mock("../../src/database", () => ({ + default: new DatabaseSync(":memory:"), +})); + +const checkSchema = () => { + const tables = database + .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-reset.ts", () => { + let consoleSpy: ReturnType; + let tempRootDir: string; + let tempDatabaseDir: string; + let migrationsDir: string; + + const rootDir = path.join(import.meta.dirname, "../.."); + + beforeAll(async () => { + // Create a unique temporary directory for this test file + tempRootDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "db-reset-test-"), + ); + tempDatabaseDir = path.join(tempRootDir, "src/database"); + migrationsDir = path.join(tempDatabaseDir, "migrations"); + + await fs.promises.mkdir(tempDatabaseDir, { recursive: true }); + // Copy real schema.sql and seeder.sql + const realDatabaseDir = path.join(rootDir, "src/database"); + await fs.promises.copyFile( + path.join(realDatabaseDir, "schema.sql"), + path.join(tempDatabaseDir, "schema.sql"), + ); + await fs.promises.copyFile( + path.join(realDatabaseDir, "seeder.sql"), + path.join(tempDatabaseDir, "seeder.sql"), + ); + }); + + beforeEach(() => { + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + afterAll(async () => { + // Clean up temporary directory + await fs.promises.rm(tempRootDir, { recursive: true, force: true }); + }); + + it("fails when given unexpected arguments", async () => { + await expect( + main(["node", "script", "--unknown-flag"], tempRootDir), + ).rejects.toThrow(/usage/i); + }); + + it("cancels 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"], tempRootDir); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); + }); + + it("loads schema, migrations and seeder in non-interactive mode", async () => { + await main(["node", "script", "-n"], tempRootDir); + + checkSchema(); + + const users = database.prepare("select * from user").all(); + + expect(users.length).toBeGreaterThan(0); + + const items = database.prepare("select * from item").all(); + + expect(items.length).toBeGreaterThan(0); + }); + + it("loads schema, migrations and seeder in interactive mode", async () => { + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "y", + close: vi.fn(), + }); + + await main(["node", "script"], tempRootDir); + + checkSchema(); + + const users = database.prepare("select * from user").all(); + + expect(users.length).toBeGreaterThan(0); + + const items = database.prepare("select * from item").all(); + + expect(items.length).toBeGreaterThan(0); + }); + + it("populates _migrations table with all executed files", async () => { + await main(["node", "script", "-n"], tempRootDir); + + const migrations = database + .prepare("select filename, checksum from _migrations") + .all() as { filename: string; checksum: string }[]; + + // Should have at least schema.sql and seeder.sql + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("schema.sql"); + expect(filenames).toContain("seeder.sql"); + + // All entries should have a checksum + for (const m of migrations) { + expect(m.checksum).toBeTruthy(); + expect(m.checksum.length).toBe(64); // SHA-256 hex + } + }); + + it("includes migration files when present", async () => { + // Create a temporary migration file + const testMigration = path.join( + migrationsDir, + "0000_test_reset_migration.sql", + ); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile(testMigration, "-- test migration for reset\n"); + + try { + await main(["node", "script", "-n"], tempRootDir); + + const migrations = database + .prepare("select filename from _migrations") + .all() as { filename: string }[]; + + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("0000_test_reset_migration.sql"); + } finally { + // Clean up + await fs.promises.unlink(testMigration); + } + }); +}); diff --git a/tests/scripts/database-sync.test.ts b/tests/scripts/database-sync.test.ts deleted file mode 100644 index c3d220d..0000000 --- a/tests/scripts/database-sync.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; - -import { main } from "../../scripts/database-sync"; -import database from "../../src/database"; - -vi.mock("../../src/database", () => ({ - default: new DatabaseSync(":memory:"), -})); - -const checkSchema = () => { - const tables = database - .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; - - beforeEach(() => { - consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("fails when no target is provided", async () => { - await expect(main(["node", "script"])).rejects.toThrow(/usage/i); - }); - - it("fails when given unexpected arguments", async () => { - await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow( - /usage/i, - ); - }); - - it("cancels 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", "both"]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); - }); - - it("loads schema in interactive mode", async () => { - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "y", - close: vi.fn(), - }); - - await main(["node", "script", "schema"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBe(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBe(0); - }); - - it("loads schema in non-interactive mode", async () => { - await main(["node", "script", "schema", "-n"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBe(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBe(0); - }); - - it("loads seeder in interactive mode", async () => { - await main(["node", "script", "schema", "-n"]); - - checkSchema(); - - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "y", - close: vi.fn(), - }); - - await main(["node", "script", "seeder"]); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); - - it("loads seeder in non-interactive mode", async () => { - await main(["node", "script", "schema", "-n"]); - - checkSchema(); - - await main(["node", "script", "seeder", "-n"]); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); - - it("loads both in interactive mode", async () => { - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "y", - close: vi.fn(), - }); - - await main(["node", "script", "both"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); - - it("loads both in non-interactive mode", async () => { - await main(["node", "script", "both", "-n"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); -}); From b78b1b661e60ac5370ea6a3e4ccb34dd8324b295 Mon Sep 17 00:00:00 2001 From: rocambille Date: Sat, 4 Jul 2026 20:23:41 +0200 Subject: [PATCH 2/4] added tests for database reset and migrate --- tests/scripts/database-migrate.test.ts | 140 ++++++++++++++++++++++++- tests/scripts/database-reset.test.ts | 8 +- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/tests/scripts/database-migrate.test.ts b/tests/scripts/database-migrate.test.ts index 130d816..dc5406d 100644 --- a/tests/scripts/database-migrate.test.ts +++ b/tests/scripts/database-migrate.test.ts @@ -6,8 +6,12 @@ import { main as migrateMain } from "../../scripts/database-migrate"; import { main as resetMain } from "../../scripts/database-reset"; import database from "../../src/database"; +let mockDb = new DatabaseSync(":memory:"); + vi.mock("../../src/database", () => ({ - default: new DatabaseSync(":memory:"), + get default() { + return mockDb; + }, })); describe("database-migrate.ts", () => { @@ -62,10 +66,34 @@ describe("database-migrate.ts", () => { it("fails when given unexpected arguments", async () => { await expect( - migrateMain(["node", "script", "--unknown-flag"], tempRootDir), + migrateMain(["node", "script", "--unknown-flag"]), + ).rejects.toThrow(/usage/i); + }); + + it("fails when given extra arguments", async () => { + await expect( + migrateMain(["node", "script", "--no-interaction", "--extra"]), ).rejects.toThrow(/usage/i); }); + it("cancels when user answers no interactively", async () => { + // Create a dummy migration + const testFile = path.join(migrationsDir, "0000_dummy.sql"); + + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile(testFile, "SELECT 'hello, world!';\n"); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await migrateMain(["node", "script"], tempRootDir); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); + }); + it("reports nothing to migrate when database is up to date", async () => { await migrateMain(["node", "script", "-n"], tempRootDir); @@ -244,4 +272,112 @@ describe("database-migrate.ts", () => { await fs.promises.unlink(badFile); } }); + + describe("File-based Database", () => { + let tempDbPath: string; + + beforeEach(async () => { + tempDbPath = path.join(tempRootDir, "test-database.sqlite"); + mockDb = new DatabaseSync(tempDbPath); + // Clean reset + await resetMain(["node", "script", "-n"], tempRootDir); + consoleSpy.mockClear(); + warnSpy.mockClear(); + }); + + afterEach(async () => { + mockDb.close(); + mockDb = new DatabaseSync(":memory:"); + await fs.promises.rm(tempDbPath, { force: true }); + await fs.promises.rm(`${tempDbPath}.bak`, { force: true }); + }); + + it("creates backup, migrates successfully and cleans up backup", async () => { + // Create a dummy migration in our isolated temp migrations directory + const testFile = path.join(migrationsDir, "0099_file_db_test.sql"); + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE file_db_test (id INTEGER PRIMARY KEY);\n", + ); + + try { + await migrateMain(["node", "script", "-n"], tempRootDir); + + // Verify it applied successfully + const tables = mockDb + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'file_db_test'", + ) + .all(); + expect(tables.length).toBe(1); + + // Verify the backup file was cleaned up (does not exist anymore) + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + } finally { + await fs.promises.unlink(testFile); + } + }); + + it("reports nothing to migrate and cleans up backup on up-to-date file database", async () => { + // Run once to ensure everything is up to date (and since there are no new migrations, pending is 0) + await migrateMain(["node", "script", "-n"], tempRootDir); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); + }); + + it("removes backup when interactive migration is cancelled", async () => { + // Create a dummy migration in our isolated temp migrations directory + const testFile = path.join(migrationsDir, "0099_cancel_test.sql"); + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE cancel_test (id INTEGER PRIMARY KEY);\n", + ); + + try { + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await migrateMain(["node", "script"], tempRootDir); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + } finally { + await fs.promises.unlink(testFile); + } + }); + + it("restores database from backup and removes backup on migration failure", async () => { + // Create a bad migration + const testFile = path.join(migrationsDir, "0099_fail_test.sql"); + await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.writeFile(testFile, "INVALID SQL STATEMENT;\n"); + + try { + await expect( + migrateMain(["node", "script", "-n"], tempRootDir), + ).rejects.toThrow(); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + // Verify database is still queryable (restored and unlocked) + const tables = mockDb + .prepare( + "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", + ) + .all(); + expect(tables.length).toBeGreaterThan(0); + } finally { + await fs.promises.unlink(testFile); + } + }); + }); }); diff --git a/tests/scripts/database-reset.test.ts b/tests/scripts/database-reset.test.ts index 05fac47..b445ba3 100644 --- a/tests/scripts/database-reset.test.ts +++ b/tests/scripts/database-reset.test.ts @@ -67,8 +67,14 @@ describe("database-reset.ts", () => { }); it("fails when given unexpected arguments", async () => { + await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow( + /usage/i, + ); + }); + + it("fails when given extra arguments", async () => { await expect( - main(["node", "script", "--unknown-flag"], tempRootDir), + main(["node", "script", "--no-interaction", "--extra"]), ).rejects.toThrow(/usage/i); }); From 0052c3982bc2a0bdafcec09c5783eb4e25bbe82c Mon Sep 17 00:00:00 2001 From: rocambille Date: Sat, 4 Jul 2026 21:10:27 +0200 Subject: [PATCH 3/4] cleaned database reset and migrate tests --- scripts/database-helpers.ts | 2 +- scripts/database-migrate.ts | 2 +- tests/scripts/database-migrate.test.ts | 364 ++++++++++++------------- tests/scripts/database-reset.test.ts | 108 ++++---- tests/scripts/test-utils.ts | 45 +++ 5 files changed, 274 insertions(+), 247 deletions(-) create mode 100644 tests/scripts/test-utils.ts diff --git a/scripts/database-helpers.ts b/scripts/database-helpers.ts index 816f91e..4750c5f 100644 --- a/scripts/database-helpers.ts +++ b/scripts/database-helpers.ts @@ -69,7 +69,7 @@ export function dropAllTables(database: DatabaseSync): void { .prepare( "select name from sqlite_schema where type ='table' and name not like 'sqlite_%'", ) - .all() as { name: string }[]; + .all(); // Prevent errors because of cascade deletion database.exec("PRAGMA foreign_keys = OFF"); diff --git a/scripts/database-migrate.ts b/scripts/database-migrate.ts index b03c6eb..6b3dc5e 100644 --- a/scripts/database-migrate.ts +++ b/scripts/database-migrate.ts @@ -62,7 +62,7 @@ export async function main( const currentChecksum = computeChecksum(sql); if (applied.has(filename)) { - const storedChecksum = applied.get(filename) as string; + const storedChecksum = applied.get(filename); if (storedChecksum !== currentChecksum) { console.warn( diff --git a/tests/scripts/database-migrate.test.ts b/tests/scripts/database-migrate.test.ts index dc5406d..06b5989 100644 --- a/tests/scripts/database-migrate.test.ts +++ b/tests/scripts/database-migrate.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { main as migrateMain } from "../../scripts/database-migrate"; import { main as resetMain } from "../../scripts/database-reset"; import database from "../../src/database"; +import { createDbTestFixture, type DbTestFixture } from "./test-utils"; let mockDb = new DatabaseSync(":memory:"); @@ -17,51 +17,27 @@ vi.mock("../../src/database", () => ({ describe("database-migrate.ts", () => { let consoleSpy: ReturnType; let warnSpy: ReturnType; - let tempRootDir: string; - let tempDatabaseDir: string; - let migrationsDir: string; - - const rootDir = path.join(import.meta.dirname, "../.."); - - beforeAll(async () => { - // Create a unique temporary directory for this test file - tempRootDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "db-migrate-test-"), - ); - tempDatabaseDir = path.join(tempRootDir, "src/database"); - migrationsDir = path.join(tempDatabaseDir, "migrations"); - - await fs.promises.mkdir(tempDatabaseDir, { recursive: true }); - // Copy real schema.sql and seeder.sql - const realDatabaseDir = path.join(rootDir, "src/database"); - await fs.promises.copyFile( - path.join(realDatabaseDir, "schema.sql"), - path.join(tempDatabaseDir, "schema.sql"), - ); - await fs.promises.copyFile( - path.join(realDatabaseDir, "seeder.sql"), - path.join(tempDatabaseDir, "seeder.sql"), - ); - }); + let dbTestFixture: DbTestFixture; beforeEach(async () => { consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - // Start from a clean reset so _migrations is populated - await resetMain(["node", "script", "-n"], tempRootDir); + // Create a unique temporary directory sandbox for each test case + const fixture = await createDbTestFixture("db-migrate-test-"); + dbTestFixture = fixture; + + // Reset database state by instantiating a fresh in-memory database and running clean reset + mockDb = new DatabaseSync(":memory:"); + await resetMain(["node", "script", "-n"], dbTestFixture.rootDir); consoleSpy.mockClear(); warnSpy.mockClear(); }); - afterEach(() => { + afterEach(async () => { consoleSpy.mockRestore(); warnSpy.mockRestore(); - }); - - afterAll(async () => { - // Clean up temporary directory - await fs.promises.rm(tempRootDir, { recursive: true, force: true }); + await dbTestFixture.cleanup(); }); it("fails when given unexpected arguments", async () => { @@ -78,9 +54,9 @@ describe("database-migrate.ts", () => { it("cancels when user answers no interactively", async () => { // Create a dummy migration - const testFile = path.join(migrationsDir, "0000_dummy.sql"); + const testFile = path.join(dbTestFixture.migrationsDir, "0000_dummy.sql"); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile(testFile, "SELECT 'hello, world!';\n"); const readline = await import("node:readline/promises"); @@ -89,13 +65,13 @@ describe("database-migrate.ts", () => { close: vi.fn(), }); - await migrateMain(["node", "script"], tempRootDir); + await migrateMain(["node", "script"], dbTestFixture.rootDir); expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); }); it("reports nothing to migrate when database is up to date", async () => { - await migrateMain(["node", "script", "-n"], tempRootDir); + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); expect(consoleSpy).toHaveBeenCalledWith( expect.stringMatching(/nothing to migrate/i), @@ -104,96 +80,93 @@ describe("database-migrate.ts", () => { it("applies a new migration file", async () => { // Create a migration that adds a test table - const testFile = path.join(migrationsDir, "0001_add_test_table.sql"); + const testFile = path.join( + dbTestFixture.migrationsDir, + "0001_add_test_table.sql", + ); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile( testFile, "CREATE TABLE test_migrate (id INTEGER PRIMARY KEY);\n", ); - try { - await migrateMain(["node", "script", "-n"], tempRootDir); + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); - // Verify the table was created - const tables = database - .prepare( - "select name from sqlite_schema where type = 'table' and name = 'test_migrate'", - ) - .all() as { name: string }[]; + // Verify the table was created + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'test_migrate'", + ) + .all(); - expect(tables.length).toBe(1); + expect(tables.length).toBe(1); - // Verify it was tracked in _migrations - const migration = database - .prepare("select * from _migrations where filename = ?") - .get("0001_add_test_table.sql") as - | { filename: string; checksum: string } - | undefined; - - expect(migration).toBeDefined(); - expect(migration?.checksum.length).toBe(64); - } finally { - await fs.promises.unlink(testFile); - } + // Verify it was tracked in _migrations + const migration = database + .prepare("select * from _migrations where filename = ?") + .get("0001_add_test_table.sql") as + | { filename: string; checksum: string } + | undefined; + + expect(migration).toBeDefined(); + expect(migration?.checksum.length).toBe(64); }); it("skips already-applied files", async () => { // Create and apply a migration - const testFile = path.join(migrationsDir, "0002_skip_test.sql"); + const testFile = path.join( + dbTestFixture.migrationsDir, + "0002_skip_test.sql", + ); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile( testFile, "CREATE TABLE skip_test (id INTEGER PRIMARY KEY);\n", ); - try { - // Apply it once - await migrateMain(["node", "script", "-n"], tempRootDir); - consoleSpy.mockClear(); + // Apply it once + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + consoleSpy.mockClear(); - // Apply again: should report nothing to migrate - await migrateMain(["node", "script", "-n"], tempRootDir); + // Apply again: should report nothing to migrate + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringMatching(/nothing to migrate/i), - ); - } finally { - await fs.promises.unlink(testFile); - } + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); }); it("warns when an applied file has been modified", async () => { // Create and apply a migration - const testFile = path.join(migrationsDir, "0003_checksum_test.sql"); + const testFile = path.join( + dbTestFixture.migrationsDir, + "0003_checksum_test.sql", + ); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile( testFile, "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY);\n", ); - try { - // Apply it - await migrateMain(["node", "script", "-n"], tempRootDir); - warnSpy.mockClear(); + // Apply it + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + warnSpy.mockClear(); - // Modify the file - await fs.promises.writeFile( - testFile, - "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY, name TEXT);\n", - ); + // Modify the file + await fs.promises.writeFile( + testFile, + "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY, name TEXT);\n", + ); - // Migrate again: should warn about modification - await migrateMain(["node", "script", "-n"], tempRootDir); + // Migrate again: should warn about modification + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringMatching(/was modified since it was applied/), - ); - } finally { - await fs.promises.unlink(testFile); - } + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/was modified since it was applied/), + ); }); it("does not apply seeder.sql", async () => { @@ -202,7 +175,7 @@ describe("database-migrate.ts", () => { database.exec("DELETE FROM user"); consoleSpy.mockClear(); - await migrateMain(["node", "script", "-n"], tempRootDir); + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); // Seeder should NOT have been re-applied const users = database.prepare("select * from user").all(); @@ -210,77 +183,87 @@ describe("database-migrate.ts", () => { }); it("respects file ordering: schema.sql first, then migrations alphabetically", async () => { - const fileA = path.join(migrationsDir, "aaa_first.sql"); - const fileB = path.join(migrationsDir, "zzz_second.sql"); + const fileA = path.join(dbTestFixture.migrationsDir, "aaa_first.sql"); + const fileB = path.join(dbTestFixture.migrationsDir, "zzz_second.sql"); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile(fileA, "-- first\n"); await fs.promises.writeFile(fileB, "-- second\n"); - try { - await migrateMain(["node", "script", "-n"], tempRootDir); + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); - const migrations = database - .prepare( - "SELECT filename FROM _migrations WHERE filename IN ('aaa_first.sql', 'zzz_second.sql') ORDER BY applied_at", - ) - .all() as { filename: string }[]; - - const filenames = migrations.map((m) => m.filename); - expect(filenames).toEqual(["aaa_first.sql", "zzz_second.sql"]); - } finally { - await fs.promises.unlink(fileA); - await fs.promises.unlink(fileB); - } + const migrations = database + .prepare( + "SELECT filename FROM _migrations WHERE filename IN ('aaa_first.sql', 'zzz_second.sql') ORDER BY applied_at", + ) + .all(); + + const filenames = migrations.map((m) => m.filename); + expect(filenames).toEqual(["aaa_first.sql", "zzz_second.sql"]); }); it("rolls back on execution failure", async () => { - const goodFile = path.join(migrationsDir, "0004_good.sql"); - const badFile = path.join(migrationsDir, "0005_bad.sql"); + const goodFile = path.join(dbTestFixture.migrationsDir, "0004_good.sql"); + const badFile = path.join(dbTestFixture.migrationsDir, "0005_bad.sql"); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile( goodFile, "CREATE TABLE good_table (id INTEGER PRIMARY KEY);\n", ); await fs.promises.writeFile(badFile, "THIS IS NOT VALID SQL;\n"); - try { - await expect( - migrateMain(["node", "script", "-n"], tempRootDir), - ).rejects.toThrow(); + await expect( + migrateMain(["node", "script", "-n"], dbTestFixture.rootDir), + ).rejects.toThrow(); + + // The good table should NOT exist (transaction was rolled back) + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'good_table'", + ) + .all(); + + expect(tables.length).toBe(0); + + // Neither file should be tracked in _migrations + const tracked = database + .prepare( + "select * from _migrations where filename IN ('0004_good.sql', '0005_bad.sql')", + ) + .all(); + + expect(tracked.length).toBe(0); + }); - // The good table should NOT exist (transaction was rolled back) - const tables = database - .prepare( - "select name from sqlite_schema where type = 'table' and name = 'good_table'", - ) - .all(); + it("skips schema.sql gracefully if it is missing", async () => { + // Start with a fresh in-memory database (bypassing beforeEach's resetMain state) + mockDb = new DatabaseSync(":memory:"); - expect(tables.length).toBe(0); + // Delete schema.sql from this sandbox + await fs.promises.unlink( + path.join(dbTestFixture.databaseDir, "schema.sql"), + ); - // Neither file should be tracked in _migrations - const tracked = database - .prepare( - "select * from _migrations where filename IN ('0004_good.sql', '0005_bad.sql')", - ) - .all(); + // Run migration + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); - expect(tracked.length).toBe(0); - } finally { - await fs.promises.unlink(goodFile); - await fs.promises.unlink(badFile); - } + // Verify schema.sql is not tracked in _migrations + const migrations = database + .prepare("select filename from _migrations") + .all(); + const filenames = migrations.map((m) => m.filename); + expect(filenames).not.toContain("schema.sql"); }); describe("File-based Database", () => { let tempDbPath: string; beforeEach(async () => { - tempDbPath = path.join(tempRootDir, "test-database.sqlite"); + tempDbPath = path.join(dbTestFixture.rootDir, "test-database.sqlite"); mockDb = new DatabaseSync(tempDbPath); // Clean reset - await resetMain(["node", "script", "-n"], tempRootDir); + await resetMain(["node", "script", "-n"], dbTestFixture.rootDir); consoleSpy.mockClear(); warnSpy.mockClear(); }); @@ -294,34 +277,33 @@ describe("database-migrate.ts", () => { it("creates backup, migrates successfully and cleans up backup", async () => { // Create a dummy migration in our isolated temp migrations directory - const testFile = path.join(migrationsDir, "0099_file_db_test.sql"); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + const testFile = path.join( + dbTestFixture.migrationsDir, + "0099_file_db_test.sql", + ); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile( testFile, "CREATE TABLE file_db_test (id INTEGER PRIMARY KEY);\n", ); - try { - await migrateMain(["node", "script", "-n"], tempRootDir); - - // Verify it applied successfully - const tables = mockDb - .prepare( - "select name from sqlite_schema where type = 'table' and name = 'file_db_test'", - ) - .all(); - expect(tables.length).toBe(1); - - // Verify the backup file was cleaned up (does not exist anymore) - expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); - } finally { - await fs.promises.unlink(testFile); - } + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + // Verify it applied successfully + const tables = mockDb + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'file_db_test'", + ) + .all(); + expect(tables.length).toBe(1); + + // Verify the backup file was cleaned up (does not exist anymore) + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); }); it("reports nothing to migrate and cleans up backup on up-to-date file database", async () => { // Run once to ensure everything is up to date (and since there are no new migrations, pending is 0) - await migrateMain(["node", "script", "-n"], tempRootDir); + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); // Verify backup was deleted expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); @@ -332,52 +314,50 @@ describe("database-migrate.ts", () => { it("removes backup when interactive migration is cancelled", async () => { // Create a dummy migration in our isolated temp migrations directory - const testFile = path.join(migrationsDir, "0099_cancel_test.sql"); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + const testFile = path.join( + dbTestFixture.migrationsDir, + "0099_cancel_test.sql", + ); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile( testFile, "CREATE TABLE cancel_test (id INTEGER PRIMARY KEY);\n", ); - try { - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "n", - close: vi.fn(), - }); + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); - await migrateMain(["node", "script"], tempRootDir); + await migrateMain(["node", "script"], dbTestFixture.rootDir); - // Verify backup was deleted - expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); - } finally { - await fs.promises.unlink(testFile); - } + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); }); it("restores database from backup and removes backup on migration failure", async () => { // Create a bad migration - const testFile = path.join(migrationsDir, "0099_fail_test.sql"); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + const testFile = path.join( + dbTestFixture.migrationsDir, + "0099_fail_test.sql", + ); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile(testFile, "INVALID SQL STATEMENT;\n"); - try { - await expect( - migrateMain(["node", "script", "-n"], tempRootDir), - ).rejects.toThrow(); - - // Verify backup was deleted - expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); - // Verify database is still queryable (restored and unlocked) - const tables = mockDb - .prepare( - "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", - ) - .all(); - expect(tables.length).toBeGreaterThan(0); - } finally { - await fs.promises.unlink(testFile); - } + await expect( + migrateMain(["node", "script", "-n"], dbTestFixture.rootDir), + ).rejects.toThrow(); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + // Verify database is still queryable (restored and unlocked) + const tables = mockDb + .prepare( + "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", + ) + .all(); + expect(tables.length).toBeGreaterThan(0); }); }); }); diff --git a/tests/scripts/database-reset.test.ts b/tests/scripts/database-reset.test.ts index b445ba3..410575e 100644 --- a/tests/scripts/database-reset.test.ts +++ b/tests/scripts/database-reset.test.ts @@ -1,13 +1,17 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { main } from "../../scripts/database-reset"; import database from "../../src/database"; +import { createDbTestFixture, type DbTestFixture } from "./test-utils"; + +let mockDb = new DatabaseSync(":memory:"); vi.mock("../../src/database", () => ({ - default: new DatabaseSync(":memory:"), + get default() { + return mockDb; + }, })); const checkSchema = () => { @@ -15,7 +19,7 @@ const checkSchema = () => { .prepare( "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", ) - .all() as { name: string }[]; + .all(); const tableNames = tables.map((t) => t.name); @@ -26,44 +30,22 @@ const checkSchema = () => { describe("database-reset.ts", () => { let consoleSpy: ReturnType; - let tempRootDir: string; - let tempDatabaseDir: string; - let migrationsDir: string; + let dbTestFixture: DbTestFixture; - const rootDir = path.join(import.meta.dirname, "../.."); + beforeEach(async () => { + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); - beforeAll(async () => { - // Create a unique temporary directory for this test file - tempRootDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "db-reset-test-"), - ); - tempDatabaseDir = path.join(tempRootDir, "src/database"); - migrationsDir = path.join(tempDatabaseDir, "migrations"); - - await fs.promises.mkdir(tempDatabaseDir, { recursive: true }); - // Copy real schema.sql and seeder.sql - const realDatabaseDir = path.join(rootDir, "src/database"); - await fs.promises.copyFile( - path.join(realDatabaseDir, "schema.sql"), - path.join(tempDatabaseDir, "schema.sql"), - ); - await fs.promises.copyFile( - path.join(realDatabaseDir, "seeder.sql"), - path.join(tempDatabaseDir, "seeder.sql"), - ); - }); + // Create a unique temporary directory sandbox for each test case + const fixture = await createDbTestFixture("db-reset-test-"); + dbTestFixture = fixture; - beforeEach(() => { - consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + // Reset database state by instantiating a fresh in-memory database + mockDb = new DatabaseSync(":memory:"); }); - afterEach(() => { + afterEach(async () => { consoleSpy.mockRestore(); - }); - - afterAll(async () => { - // Clean up temporary directory - await fs.promises.rm(tempRootDir, { recursive: true, force: true }); + await dbTestFixture.cleanup(); }); it("fails when given unexpected arguments", async () => { @@ -85,13 +67,19 @@ describe("database-reset.ts", () => { close: vi.fn(), }); - await main(["node", "script"], tempRootDir); + await main(["node", "script"], dbTestFixture.rootDir); expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); }); it("loads schema, migrations and seeder in non-interactive mode", async () => { - await main(["node", "script", "-n"], tempRootDir); + // Run once to create tables + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + checkSchema(); + + // Run a second time: tables now exist and should be dropped successfully + await main(["node", "script", "-n"], dbTestFixture.rootDir); checkSchema(); @@ -111,7 +99,7 @@ describe("database-reset.ts", () => { close: vi.fn(), }); - await main(["node", "script"], tempRootDir); + await main(["node", "script"], dbTestFixture.rootDir); checkSchema(); @@ -125,7 +113,7 @@ describe("database-reset.ts", () => { }); it("populates _migrations table with all executed files", async () => { - await main(["node", "script", "-n"], tempRootDir); + await main(["node", "script", "-n"], dbTestFixture.rootDir); const migrations = database .prepare("select filename, checksum from _migrations") @@ -144,27 +132,41 @@ describe("database-reset.ts", () => { }); it("includes migration files when present", async () => { - // Create a temporary migration file + // Create a temporary migration file in our sandbox const testMigration = path.join( - migrationsDir, + dbTestFixture.migrationsDir, "0000_test_reset_migration.sql", ); - await fs.promises.mkdir(migrationsDir, { recursive: true }); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); await fs.promises.writeFile(testMigration, "-- test migration for reset\n"); - try { - await main(["node", "script", "-n"], tempRootDir); + await main(["node", "script", "-n"], dbTestFixture.rootDir); - const migrations = database - .prepare("select filename from _migrations") - .all() as { filename: string }[]; + const migrations = database + .prepare("select filename from _migrations") + .all(); - const filenames = migrations.map((m) => m.filename); - expect(filenames).toContain("0000_test_reset_migration.sql"); - } finally { - // Clean up - await fs.promises.unlink(testMigration); - } + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("0000_test_reset_migration.sql"); + }); + + it("skips seeder.sql gracefully if it is missing", async () => { + // Delete seeder.sql from this sandbox + await fs.promises.unlink( + path.join(dbTestFixture.databaseDir, "seeder.sql"), + ); + + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + checkSchema(); + + // Verify seeder.sql was not tracked in _migrations + const migrations = database + .prepare("select filename from _migrations") + .all(); + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("schema.sql"); + expect(filenames).not.toContain("seeder.sql"); }); }); diff --git a/tests/scripts/test-utils.ts b/tests/scripts/test-utils.ts new file mode 100644 index 0000000..e15926d --- /dev/null +++ b/tests/scripts/test-utils.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface DbTestFixture { + rootDir: string; + databaseDir: string; + migrationsDir: string; + cleanup: () => Promise; +} + +/** + * Creates a unique isolated database sandbox workspace under os.tmpdir() + * and populates it with the project's schema.sql and seeder.sql. + */ +export async function createDbTestFixture( + prefix: string, +): Promise { + const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); + const databaseDir = path.join(rootDir, "src/database"); + const migrationsDir = path.join(databaseDir, "migrations"); + + await fs.promises.mkdir(databaseDir, { recursive: true }); + + const projectRoot = path.join(import.meta.dirname, "../.."); + await fs.promises.copyFile( + path.join(projectRoot, "src/database/schema.sql"), + path.join(databaseDir, "schema.sql"), + ); + await fs.promises.copyFile( + path.join(projectRoot, "src/database/seeder.sql"), + path.join(databaseDir, "seeder.sql"), + ); + + const cleanup = async () => { + await fs.promises.rm(rootDir, { recursive: true, force: true }); + }; + + return { + rootDir, + databaseDir, + migrationsDir, + cleanup, + }; +} From 7c946d9f7d85f297accd08511459dd8558d39d8c Mon Sep 17 00:00:00 2001 From: rocambille Date: Sun, 5 Jul 2026 11:47:12 +0200 Subject: [PATCH 4/4] added tests to cover 100% of scripts --- tests/scripts/database-migrate.test.ts | 27 ++++++++++ tests/scripts/make-clone.test.ts | 32 ++++++++++++ tests/scripts/make-purge.test.ts | 68 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/tests/scripts/database-migrate.test.ts b/tests/scripts/database-migrate.test.ts index 06b5989..ad70330 100644 --- a/tests/scripts/database-migrate.test.ts +++ b/tests/scripts/database-migrate.test.ts @@ -70,6 +70,33 @@ describe("database-migrate.ts", () => { expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); }); + it("proceeds when user answers yes interactively", async () => { + // Create a dummy migration + const testFile = path.join(dbTestFixture.migrationsDir, "0000_dummy.sql"); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE interactive_test (id INTEGER PRIMARY KEY);\n", + ); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "y", + close: vi.fn(), + }); + + await migrateMain(["node", "script"], dbTestFixture.rootDir); + + // Verify it migrated successfully + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'interactive_test'", + ) + .all(); + expect(tables.length).toBe(1); + }); + it("reports nothing to migrate when database is up to date", async () => { await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); diff --git a/tests/scripts/make-clone.test.ts b/tests/scripts/make-clone.test.ts index 7f96d34..cc1809f 100644 --- a/tests/scripts/make-clone.test.ts +++ b/tests/scripts/make-clone.test.ts @@ -149,4 +149,36 @@ describe("make-clone.ts", () => { expect(unrelatedContent).toBe("export class Unrelated { apple = 10; }\n"); }); + + it("clones to src/express/modules and logs checklist instructions", async () => { + const src = path.join(tmpDir, "src", "cherry.ts"); + const dest = path.join(tmpDir, "src", "express", "modules", "berry.ts"); + + await fs.ensureDir(path.dirname(src)); + await fs.writeFile(src, "export const cherry = 'sweet';\n"); + + await main(["node", "script", src, dest, "cherry", "berry"]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching( + /Register the Express module in src\/express\/routes.ts/, + ), + ); + }); + + it("clones to src/react/components and logs checklist instructions", async () => { + const src = path.join(tmpDir, "src", "cherry.ts"); + const dest = path.join(tmpDir, "src", "react", "components", "berry.ts"); + + await fs.ensureDir(path.dirname(src)); + await fs.writeFile(src, "export const cherry = 'sweet';\n"); + + await main(["node", "script", src, dest, "cherry", "berry"]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching( + /Register the React routes in src\/react\/routes.tsx/, + ), + ); + }); }); diff --git a/tests/scripts/make-purge.test.ts b/tests/scripts/make-purge.test.ts index 1cb82a0..cfa511a 100644 --- a/tests/scripts/make-purge.test.ts +++ b/tests/scripts/make-purge.test.ts @@ -4,6 +4,10 @@ import fs from "fs-extra"; import { main } from "../../scripts/make-purge"; +class TestError extends Error { + code?: string; +} + const projectRoot = path.join(import.meta.dirname, "../.."); /** @@ -68,6 +72,9 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { expect( await fs.pathExists(path.join(tmpDir, "src/express/modules/auth")), ).toBe(false); + + // Run second time to ensure idempotency and cover unmodified files path + await main(["node", "script", "-n"], tmpDir); }); it("runs purge for items only with --keep-auth", async () => { @@ -405,4 +412,65 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { expect(result).toContain("itemRoutes"); }); }); + + describe("error handling in remove", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("logs console error when fs.remove fails with non-ENOENT error", async () => { + const mockError = new TestError("Permission denied"); + mockError.code = "EACCES"; + const removeSpy = vi.spyOn(fs, "remove").mockRejectedValueOnce(mockError); + + await main(["node", "script", "-n"], tmpDir); + + expect(errorSpy).toHaveBeenCalled(); + removeSpy.mockRestore(); + }); + + it("silently ignores ENOENT error in remove", async () => { + const mockError = new TestError("File not found"); + mockError.code = "ENOENT"; + const removeSpy = vi.spyOn(fs, "remove").mockRejectedValueOnce(mockError); + + await main(["node", "script", "-n"], tmpDir); + + expect(errorSpy).not.toHaveBeenCalled(); + removeSpy.mockRestore(); + }); + }); + + describe("error handling in updateFile", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("logs console error when fs.readFile fails with non-ENOENT error", async () => { + await scaffoldProject(tmpDir); + + const mockError = new TestError("Permission denied"); + mockError.code = "EACCES"; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(mockError); + + await main(["node", "script", "-n"], tmpDir); + + expect(errorSpy).toHaveBeenCalled(); + readFileSpy.mockRestore(); + }); + }); });