Files
SolidScribe/server/tests/schema/migrations.test.js
T
2026-11-03 12:53:17 -07:00

221 lines
7.5 KiB
JavaScript

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://[email protected]: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/)
})
})