Study/tests/helpers/testDatabase.ts

107 lines
3.4 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
} from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
export interface DisposableDatabase {
databaseUrl: string;
databasePath: string;
directory: string;
}
function pathFromDatabaseUrl(databaseUrl: string, cwd = process.cwd()) {
if (!databaseUrl.startsWith("file:")) {
throw new Error("Tests require a file: SQLite DATABASE_URL");
}
const withoutScheme = databaseUrl.slice("file:".length).split("?")[0];
return path.resolve(cwd, withoutScheme);
}
export function assertDisposableTestDatabase(databaseUrl: string) {
const databasePath = pathFromDatabaseUrl(databaseUrl);
const normalized = databasePath.replaceAll("\\", "/").toLowerCase();
if (
normalized.endsWith("/dev.db") ||
normalized === "/app/data/study.db" ||
existsSync(databasePath) ||
!path.basename(databasePath).includes(".test.")
) {
throw new Error(`Refusing to use non-disposable database: ${databasePath}`);
}
return databasePath;
}
export function createDisposableDatabase(): DisposableDatabase {
const directory = path.join(
process.cwd(),
".test-databases",
`study-desk-tests-${randomUUID()}`
);
mkdirSync(directory, { recursive: true });
const databasePath = path.join(directory, `${randomUUID()}.test.db`);
const relativePath = path.relative(process.cwd(), databasePath).replaceAll("\\", "/");
const databaseUrl = `file:./${relativePath}`;
assertDisposableTestDatabase(databaseUrl);
return { databaseUrl, databasePath, directory };
}
export function applyCommittedMigrations(
database: DisposableDatabase,
options: { exclude?: string[]; through?: string } = {}
) {
assertDisposableTestDatabase(database.databaseUrl);
const migrationRoot = path.join(process.cwd(), "prisma", "migrations");
const migrationNames = readdirSync(migrationRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => !(options.exclude ?? []).includes(name))
.filter((name) => !options.through || name <= options.through)
.sort();
const client = new Database(database.databasePath);
try {
client.pragma("foreign_keys = ON");
client.exec(`
CREATE TABLE "_prisma_migrations" (
"id" TEXT NOT NULL PRIMARY KEY,
"checksum" TEXT NOT NULL,
"finished_at" DATETIME,
"migration_name" TEXT NOT NULL,
"logs" TEXT,
"rolled_back_at" DATETIME,
"started_at" DATETIME NOT NULL DEFAULT current_timestamp,
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
);
`);
const recordMigration = client.prepare(`
INSERT INTO "_prisma_migrations"
("id", "checksum", "finished_at", "migration_name", "applied_steps_count")
VALUES (?, ?, current_timestamp, ?, 1)
`);
for (const migrationName of migrationNames) {
const sql = readFileSync(
path.join(migrationRoot, migrationName, "migration.sql"),
"utf8"
);
client.transaction(() => {
client.exec(sql);
recordMigration.run(
randomUUID(),
createHash("sha256").update(sql).digest("hex"),
migrationName
);
})();
}
} finally {
client.close();
}
}
export function disposeDisposableDatabase(database: DisposableDatabase) {
rmSync(database.directory, { recursive: true, force: true });
}