2026-05-26 11:16:28 -06:00
|
|
|
// Migration Idempotency Integration Test
|
|
|
|
|
// This test requires a running PostgreSQL instance with DATABASE_URL configured in backend/.env.
|
|
|
|
|
// It runs ALL Postgres migrations twice (via run-all.js) to verify they are idempotent (safe to re-run),
|
|
|
|
|
// then checks that key tables and columns exist.
|
|
|
|
|
//
|
2026-05-26 11:22:39 -06:00
|
|
|
// SKIPS AUTOMATICALLY when DATABASE_URL is not set (e.g., in CI environments without DB access).
|
|
|
|
|
//
|
2026-05-26 11:16:28 -06:00
|
|
|
// Run separately: npx jest backend/__tests__/migrations-idempotency.integration.test.js --forceExit
|
|
|
|
|
|
|
|
|
|
const { execSync } = require('child_process');
|
|
|
|
|
const path = require('path');
|
2026-05-26 11:22:39 -06:00
|
|
|
const fs = require('fs');
|
2026-05-26 11:16:28 -06:00
|
|
|
|
|
|
|
|
const BACKEND_DIR = path.join(__dirname, '..');
|
|
|
|
|
|
2026-05-26 11:22:39 -06:00
|
|
|
// Load .env manually to check for DATABASE_URL without triggering db.js process.exit
|
|
|
|
|
function loadEnvFile() {
|
|
|
|
|
const envPath = path.join(BACKEND_DIR, '.env');
|
|
|
|
|
if (!fs.existsSync(envPath)) return {};
|
|
|
|
|
const content = fs.readFileSync(envPath, 'utf8');
|
|
|
|
|
const vars = {};
|
|
|
|
|
for (const line of content.split('\n')) {
|
|
|
|
|
const trimmed = line.trim();
|
|
|
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
|
|
|
const eqIdx = trimmed.indexOf('=');
|
|
|
|
|
if (eqIdx === -1) continue;
|
|
|
|
|
vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
|
|
|
}
|
|
|
|
|
return vars;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const envVars = loadEnvFile();
|
|
|
|
|
const hasDatabase = !!(process.env.DATABASE_URL || envVars.DATABASE_URL);
|
|
|
|
|
|
|
|
|
|
// Skip entire suite if no database is available
|
|
|
|
|
const describeIfDb = hasDatabase ? describe : describe.skip;
|
|
|
|
|
|
|
|
|
|
let pool;
|
|
|
|
|
|
|
|
|
|
if (hasDatabase) {
|
|
|
|
|
// Set DATABASE_URL in process.env so db.js picks it up
|
|
|
|
|
if (!process.env.DATABASE_URL && envVars.DATABASE_URL) {
|
|
|
|
|
process.env.DATABASE_URL = envVars.DATABASE_URL;
|
|
|
|
|
}
|
|
|
|
|
pool = require('../db');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:16:28 -06:00
|
|
|
function runAllMigrations() {
|
|
|
|
|
execSync('node migrations/run-all.js', {
|
|
|
|
|
cwd: BACKEND_DIR,
|
|
|
|
|
stdio: 'pipe',
|
|
|
|
|
timeout: 30000,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
2026-05-26 11:22:39 -06:00
|
|
|
if (pool) await pool.end();
|
2026-05-26 11:16:28 -06:00
|
|
|
});
|
|
|
|
|
|
2026-05-26 11:22:39 -06:00
|
|
|
describeIfDb('Migration Idempotency', () => {
|
2026-05-26 11:16:28 -06:00
|
|
|
it('runs all migrations twice without errors (idempotent)', () => {
|
|
|
|
|
// First run
|
|
|
|
|
runAllMigrations();
|
|
|
|
|
// Second run — should not throw if migrations are truly idempotent
|
|
|
|
|
runAllMigrations();
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
it('key tables exist after migrations', async () => {
|
|
|
|
|
const expectedTables = [
|
|
|
|
|
'compliance_items',
|
|
|
|
|
'compliance_item_history',
|
|
|
|
|
'compliance_notes',
|
|
|
|
|
'jira_tickets',
|
|
|
|
|
'ivanti_fp_submissions',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const { rows } = await pool.query(`
|
|
|
|
|
SELECT table_name
|
|
|
|
|
FROM information_schema.tables
|
|
|
|
|
WHERE table_schema = 'public'
|
|
|
|
|
AND table_name = ANY($1)
|
|
|
|
|
`, [expectedTables]);
|
|
|
|
|
|
|
|
|
|
const foundTables = rows.map(r => r.table_name);
|
|
|
|
|
for (const table of expectedTables) {
|
|
|
|
|
expect(foundTables).toContain(table);
|
|
|
|
|
}
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
it('compliance_item_history has expected columns', async () => {
|
|
|
|
|
const expectedColumns = [
|
|
|
|
|
'id',
|
|
|
|
|
'hostname',
|
|
|
|
|
'field_name',
|
|
|
|
|
'old_value',
|
|
|
|
|
'new_value',
|
|
|
|
|
'change_reason',
|
|
|
|
|
'changed_by',
|
|
|
|
|
'changed_at',
|
|
|
|
|
'metric_id',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const { rows } = await pool.query(`
|
|
|
|
|
SELECT column_name
|
|
|
|
|
FROM information_schema.columns
|
|
|
|
|
WHERE table_schema = 'public'
|
|
|
|
|
AND table_name = 'compliance_item_history'
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const foundColumns = rows.map(r => r.column_name);
|
|
|
|
|
for (const col of expectedColumns) {
|
|
|
|
|
expect(foundColumns).toContain(col);
|
|
|
|
|
}
|
|
|
|
|
}, 30000);
|
|
|
|
|
});
|