require('module-alias/register') const { execFileSync } = require('child_process') const fs = require('fs') const path = require('path') const REPO = path.join(__dirname, '..', '..', '..') // // These shell out to the Prisma CLI, which only exists inside nix-shell. Skip // rather than fail when it is absent, so `npx jest` still works outside the // shell -- but do NOT skip silently in a way that hides real drift when it IS // available. // const hasPrisma = (() => { try { execFileSync('prisma', ['--version'], { stdio: 'ignore' }) return true } catch (error) { return false } })() const describeWithPrisma = hasPrisma ? describe : describe.skip if (!hasPrisma) { console.log('[schema tests] prisma not on PATH - skipping. Run inside nix-shell to exercise these.') } const prisma = (args) => execFileSync('prisma', args, { cwd: REPO, encoding: 'utf8', env: process.env, }) describe('committed migration artifacts', () => { test('the baseline migration and lock file are present', () => { expect(fs.existsSync(path.join(REPO, 'prisma/schema.prisma'))).toBe(true) expect(fs.existsSync(path.join(REPO, 'prisma/migrations/0_init/migration.sql'))).toBe(true) expect(fs.existsSync(path.join(REPO, 'prisma/migrations/migration_lock.toml'))).toBe(true) }) // // Prisma emits COLLATE utf8mb4_unicode_ci for MySQL, but every table in this // database is utf8mb4_general_ci, and `prisma migrate diff` does NOT report // the difference. So a regenerated migration would silently build a // differently-collated database. This asserts the hand-correction is intact. // test('no migration declares a collation other than utf8mb4_general_ci', () => { const dir = path.join(REPO, 'prisma/migrations') const files = fs.readdirSync(dir, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => path.join(dir, entry.name, 'migration.sql')) .filter(file => fs.existsSync(file)) expect(files.length).toBeGreaterThan(0) files.forEach(file => { const ddl = fs.readFileSync(file, 'utf8') .split('\n') .filter(line => !line.trim().startsWith('--')) .join('\n') const collations = [...ddl.matchAll(/COLLATE\s+([A-Za-z0-9_]+)/gi)].map(match => match[1]) collations.forEach(collation => { expect(collation).toBe('utf8mb4_general_ci') }) }) }) test('the baseline creates the FULLTEXT index, not a plain one', () => { const sql = fs.readFileSync(path.join(REPO, 'prisma/migrations/0_init/migration.sql'), 'utf8') expect(sql).toMatch(/FULLTEXT INDEX `attachment_text_index`/) // A plain INDEX of that name would be the silent-failure mode. expect(sql).not.toMatch(/^\s*INDEX `attachment_text_index`/m) }) // // @@fulltext is GA in Prisma 7 -- the `fullTextIndex` preview flag no longer // exists in the CLI at all (verified: zero occurrences in the 7.8.0 bundle). // It was silently ignored rather than rejected, which makes it exactly the // kind of dead config that misleads the next reader. // test('the schema declares @@fulltext and no longer carries the dead preview flag', () => { const schema = fs.readFileSync(path.join(REPO, 'prisma/schema.prisma'), 'utf8') expect(schema).toMatch(/@@fulltext\(\[text\]/) expect(schema).not.toMatch(/fullTextIndex/) }) // // No generator block means `prisma generate` cannot produce a client into // this repo. That is the structural guarantee behind "migrations only" -- // stronger than a convention, because it cannot be done by accident. // test('the schema declares no generator, so no client can be generated', () => { const schema = fs.readFileSync(path.join(REPO, 'prisma/schema.prisma'), 'utf8') expect(schema).not.toMatch(/generator\s+\w+\s*\{/) expect(schema).toMatch(/datasource\s+db\s*\{/) }) // // The running server must never depend on Prisma. // test('no application code imports prisma', () => { const { execFileSync } = require('child_process') let hits = '' try { hits = execFileSync('grep', [ '-rn', '--include=*.js', '-E', "require\\(['\"]@?prisma", path.join(REPO, 'server'), ], { encoding: 'utf8' }) } catch (error) { hits = '' // grep exits 1 when nothing matches } expect(hits.trim()).toBe('') }) test('no @relation is introduced - this database has no foreign keys', () => { // Adding relations would mean adding FK constraints to a live database, // which is a schema change well outside adopting a migration tool. const schema = fs.readFileSync(path.join(REPO, 'prisma/schema.prisma'), 'utf8') expect(schema).not.toMatch(/@relation/) }) }) describeWithPrisma('schema drift', () => { // // The check that keeps schema.prisma honest: if the live database and the // committed schema ever diverge, this fails. // test('schema.prisma matches the live database exactly', () => { const output = prisma([ 'migrate', 'diff', '--from-config-datasource', '--to-schema', 'prisma/schema.prisma', '--script', ]) expect(output).toMatch(/This is an empty migration/) }) // // The load-bearing guarantee: the committed migrations, applied to an empty // database, reproduce the live schema exactly. // // Done by actually rebuilding into a scratch database and comparing // information_schema, rather than via `migrate diff --from-migrations` // (which requires a shadow database) -- and this is the stronger check // anyway, because it also catches collation drift, which Prisma's own diff // does NOT report. // test('the committed migrations rebuild the live schema exactly', () => { const db = require('@config/database') const SCRATCH = 'prisma_drift_check' const mysqlBin = (() => { try { execFileSync('mariadb', ['--version'], { stdio: 'ignore' }); return 'mariadb' } catch (error) { return 'mysql' } })() const adminArgs = ['-u', 'root'] const socket = path.join(REPO, '.mysql', 'mysql.sock') if (fs.existsSync(socket)) { adminArgs.push(`--socket=${socket}`) } const admin = (sql, database) => execFileSync( mysqlBin, [...adminArgs, '-N', '-B', ...(database ? [database] : []), '-e', sql], { encoding: 'utf8' } ) try { admin(`DROP DATABASE IF EXISTS \`${SCRATCH}\`; CREATE DATABASE \`${SCRATCH}\`;`) execFileSync('prisma', ['migrate', 'deploy'], { cwd: REPO, encoding: 'utf8', env: { ...process.env, DATABASE_URL: `mysql://root@127.0.0.1:3306/${SCRATCH}` }, }) const snapshot = (schema) => ({ columns: admin( `SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${schema}' AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME, COLUMN_NAME` ), indexes: admin( `SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE, INDEX_TYPE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='${schema}' AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX` ), collations: admin( `SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA='${schema}' AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME` ), }) const live = snapshot('application') const rebuilt = snapshot(SCRATCH) expect(rebuilt.columns).toBe(live.columns) expect(rebuilt.indexes).toBe(live.indexes) expect(rebuilt.collations).toBe(live.collations) } finally { try { admin(`DROP DATABASE IF EXISTS \`${SCRATCH}\`;`) } catch (error) { /* best effort */ } } }) test('migrate status reports no pending migrations', () => { const output = prisma(['migrate', 'status']) expect(output).toMatch(/Database schema is up to date/) }) })