Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add higher-level db migration tests #1252

Open
wants to merge 45 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a7e34da
initial framework
Oct 29, 2024
fd96542
working test framework
Oct 29, 2024
17e41df
wip
Oct 29, 2024
f39d515
wip
Oct 29, 2024
9494638
revert changes to lib
Oct 29, 2024
3d5e337
restore migrations in cleanup
Oct 29, 2024
c8df2a7
add missing import
Oct 29, 2024
f59961d
lint
Oct 29, 2024
288b03c
Merge branch 'master' into db-migration-tests
Nov 11, 2024
cf2e099
Merge branch 'master' into db-migration-tests
Nov 13, 2024
082998f
rename file
Nov 13, 2024
0934fbf
move up log declaration
Nov 13, 2024
fae9a22
check has run in different way
Nov 13, 2024
23fdaa6
ci: add workflow
Nov 13, 2024
d70880f
wip
Nov 13, 2024
055fc63
ci: create db
Nov 13, 2024
83b3dad
remove big log
Nov 13, 2024
ba2470a
decrease test timeout
Nov 13, 2024
b5fcd12
re-add ci
Nov 13, 2024
52add3b
Add .only() and .skip()
Nov 13, 2024
331e29f
lint
Nov 13, 2024
bcf7574
Merge branch 'master' into db-migration-tests
Nov 14, 2024
36b7581
re-instate soak test yml
Nov 14, 2024
3854147
Merge branch 'master' into db-migration-tests
Nov 20, 2024
9d37c6e
ci: fix test name
Nov 20, 2024
841d8da
comment, var name
Nov 20, 2024
2bf5e10
fix error message
Nov 20, 2024
9da3542
remove asyncs
Nov 20, 2024
7a60283
remove global assert
Nov 20, 2024
8482ed4
Migrator: update comments
Nov 20, 2024
b1b738f
ci: add more dependencies
Nov 20, 2024
1e6bb2b
Clean database before use
Nov 20, 2024
e746eae
delete all logging
Nov 20, 2024
193e0c5
Revert "delete all logging"
Nov 20, 2024
d251072
clean up logging
Nov 20, 2024
b4b5a10
remove lint rule: key-spacing
Nov 20, 2024
3e5e3bb
remove eslint rule no-plusplus
Nov 20, 2024
a60e98d
remove eslint rule exception : object-curly-newline
Nov 20, 2024
e6edbdd
eslint: no-multi-spaces
Nov 20, 2024
fc02afc
eslint: prefer-arrow-callback
Nov 20, 2024
8a3c7ac
eslint: keyword-spacing
Nov 20, 2024
d8280a1
eslint: remove no-use-before-define
Nov 20, 2024
5c24e50
Merge branch 'master' into db-migration-tests
Dec 10, 2024
02cc37b
run on PRs
Dec 10, 2024
6317a80
update node version
Dec 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mocharc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

module.exports = {
ignore: [
'test/db-migrations/**',
'test/e2e/**',
],
};
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ test: lint
test-ci: lint
BCRYPT=insecure npx mocha --recursive --reporter test/ci-mocha-reporter.js

.PHONY: test-db-migrations
test-db-migrations:
NODE_CONFIG_ENV=db-migration-test npx mocha --bail --sort --timeout=20000 \
--require test/db-migrations/mocha-setup.db-migrations.js \
./test/db-migrations/**/*.spec.js

.PHONY: test-fast
test-fast: node_version
BCRYPT=insecure npx mocha --recursive --fgrep @slow --invert
Expand Down
10 changes: 10 additions & 0 deletions config/db-migration-test.json
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"default": {
"database": {
"host": "localhost",
"user": "jubilant",
"password": "jubilant",
"database": "jubilant_test"
}
}
}
19 changes: 19 additions & 0 deletions test/db-migrations/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module.exports = {
extends: '../.eslintrc.js',
rules: {
'key-spacing': 'off',
'keyword-spacing': 'off',
'no-console': 'off',
'no-multi-spaces': 'off',
'no-plusplus': 'off',
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
'no-use-before-define': 'off',
'object-curly-newline': 'off',
'prefer-arrow-callback': 'off',
},
globals: {
assert: false,
db: false,
log: false,
sql: false,
},
};
1 change: 1 addition & 0 deletions test/db-migrations/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/.holding-pen/
13 changes: 13 additions & 0 deletions test/db-migrations/1900-test-first.spec.js
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Test order is very important, so if this test fails then the whole suite may
// be doing unexpected things.

describe('1900-test-first', () => {
after(() => {
global.firstHasBeenRun = true;
});

it('should be run first', () => {
// expect
assert.equal(global.firstHasBeenRun, undefined);
});
});
46 changes: 46 additions & 0 deletions test/db-migrations/20241008-01-add-user_preferences.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const {
assertIndexExists,
assertTableDoesNotExist,
assertTableSchema,
describeMigration,
} = require('./utils');

describeMigration('20241008-01-add-user_preferences', ({ runMigrationBeingTested }) => {
before(async () => {
await assertTableDoesNotExist('user_site_preferences');
await assertTableDoesNotExist('user_project_preferences');

await runMigrationBeingTested();
});

it('should create user_site_preferences table', async () => {
await assertTableSchema('user_site_preferences',
{ column_name:'userId', is_nullable:'NO', data_type:'integer' },
{ column_name:'propertyName', is_nullable:'NO', data_type:'text' },
{ column_name:'propertyValue', is_nullable:'NO', data_type:'jsonb' },
);
});

it('should create user_site_preferences userId index', async () => {
await assertIndexExists(
'user_site_preferences',
'CREATE INDEX "user_site_preferences_userId_idx" ON public.user_site_preferences USING btree ("userId")',
);
});

it('should create user_project_preferences table', async () => {
await assertTableSchema('user_project_preferences',
{ column_name:'userId', is_nullable:'NO', data_type:'integer' },
{ column_name:'projectId', is_nullable:'NO', data_type:'integer' },
{ column_name:'propertyName', is_nullable:'NO', data_type:'text' },
{ column_name:'propertyValue', is_nullable:'NO', data_type:'jsonb' },
);
});

it('should create user_project_preferences userId index', async () => {
await assertIndexExists(
'user_project_preferences',
'CREATE INDEX "user_project_preferences_userId_idx" ON public.user_project_preferences USING btree ("userId")',
);
});
});
13 changes: 13 additions & 0 deletions test/db-migrations/3000-test-last.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Test order is very important, so if this test fails then the whole suite may
// be doing unexpected things.

describe('3000-test-last', () => {
it('should NOT be run first', () => {
// expect
assert.equal(global.firstHasBeenRun, true);
});

it('should be LAST run', function() {
// FIXME work out some way to test this
});
});
96 changes: 96 additions & 0 deletions test/db-migrations/migrator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
module.exports = {
exists,
runBefore,
runIncluding,
restoreMigrations,
};

const fs = require('node:fs');
const { execSync } = require('node:child_process');

// Horrible hacks. Without this:
//
// 1. production migration code needs modifying, and
// 2. it takes 3 mins+ just to run the migrations
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved

const migrationsDir = './lib/model/migrations';
const holdingPen = './test/db-migrations/.holding-pen';

fs.mkdirSync(holdingPen, { recursive:true });

restoreMigrations();
const allMigrations = loadMigrationsList();
moveMigrationsToHoldingPen();

let lastRunIdx = -1;

function runBefore(migrationName) {
const idx = getIndex(migrationName);
if(idx === 0) return;

const previousMigration = allMigrations[idx - 1];

log('previousMigration:', previousMigration);
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved

return runIncluding(previousMigration);
}

function runIncluding(lastMigrationToRun) {
const finalIdx = getIndex(lastMigrationToRun);

for(let restoreIdx=lastRunIdx+1; restoreIdx<=finalIdx; ++restoreIdx) {
const f = allMigrations[restoreIdx] + '.js';
fs.renameSync(`${holdingPen}/${f}`, `${migrationsDir}/${f}`);
matthew-white marked this conversation as resolved.
Show resolved Hide resolved
}

log('Running migrations until:', lastMigrationToRun, '...');
const res = execSync(`node ./lib/bin/run-migrations.js`, { encoding:'utf8' });
matthew-white marked this conversation as resolved.
Show resolved Hide resolved

lastRunIdx = finalIdx;

log(`Ran migrations up-to-and-including ${lastMigrationToRun}:\n`, res);
}

function getIndex(migrationName) {
const idx = allMigrations.indexOf(migrationName);
log('getIndex()', migrationName, 'found at', idx);
if(idx === -1) throw new Error(`Unknown migration: ${migrationName}`);
return idx;
}

function restoreMigrations() {
moveAll(holdingPen, migrationsDir);
}

function moveMigrationsToHoldingPen() {
moveAll(migrationsDir, holdingPen);
}

function moveAll(src, tgt) {
fs.readdirSync(src)
.forEach(f => fs.renameSync(`${src}/${f}`, `${tgt}/${f}`));
}

function loadMigrationsList() {
const migrations = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.js'))
.map(f => f.replace(/\.js$/, ''))
.sort(); // TODO check that this is how knex sorts migration files
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
log();
log('All migrations:');
log();
migrations.forEach(m => log('*', m));
log();
log('Total:', migrations.length);
log();
return migrations;
}

function exists(migrationName) {
try {
getIndex(migrationName);
return true;
} catch(err) {
return false;
}
}
52 changes: 52 additions & 0 deletions test/db-migrations/mocha-setup.db-migrations.js
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
global.assert = require('node:assert');
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
const fs = require('node:fs');
const slonik = require('slonik');
const migrator = require('./migrator');

const _log = level => (...args) => console.log(level, ...args);
global.log = _log('[INFO]');

async function mochaGlobalSetup() {
log('mochaGlobalSetup() :: ENTRY');

global.assert = assert;

global.sql = slonik.sql;

const { user, password, host, database } = jsonFile('./config/db-migration-test.json').default.database;
const dbUrl = `postgres://${user}:${password}@${host}/${database}`;
log('dbUrl:', dbUrl);
global.db = slonik.createPool(dbUrl);
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved

const existingTables = await db.oneFirst(sql`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public'`);
if(existingTables) {
console.log(`
Existing tables were found in the public database schema. Reset the database before running migration tests.

If you are using odk-postgres14 docker, try:

docker exec odk-postgres14 psql -U postgres ${database} -c "
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
GRANT ALL ON SCHEMA public TO public;
"
`);
process.exit(1);
}

log('mochaGlobalSetup() :: EXIT');
}

function mochaGlobalTeardown() {
log('mochaGlobalTeardown() :: ENTRY');
db?.end();
migrator.restoreMigrations();
log('mochaGlobalTeardown() :: EXIT');
}

module.exports = { mochaGlobalSetup, mochaGlobalTeardown };

function jsonFile(path) {
return JSON.parse(fs.readFileSync(path, { encoding:'utf8' }));
}
117 changes: 117 additions & 0 deletions test/db-migrations/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
module.exports = {
assertIndexExists,
assertTableDoesNotExist,
assertTableSchema,
describeMigration,
};

const _ = require('lodash');
const migrator = require('./migrator');

function describeMigration(migrationName, fn) {
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
assert.ok(migrator.exists(migrationName));

assert.strictEqual(typeof fn, 'function');
assert.strictEqual(fn.length, 1);

assert.strictEqual(arguments.length, 2);

const runMigrationBeingTested = (() => {
let alreadyRun;
return async () => {
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
if(alreadyRun) throw new Error('Migration has already run! Check your test structure.');
alreadyRun = true;
migrator.runIncluding(migrationName);
};
})();

return describe(`database migration: ${migrationName}`, () => {
before(async () => {
migrator.runBefore(migrationName);
});
return fn({ runMigrationBeingTested });
});
}

async function assertIndexExists(tableName, expected) {
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
if(arguments.length !== 2) throw new Error('Incorrect arg count.');
const actualIndexes = await db.anyFirst(sql`SELECT indexdef FROM pg_indexes WHERE tablename=${tableName}`);

if(actualIndexes.includes(expected)) return true;
assert.fail(
'Could not find expected index:\njson=' +
JSON.stringify({ expected, actualIndexes, }),
);
}

async function assertTableExists(tableName) {
const count = await db.oneFirst(sql`SELECT COUNT(*) FROM information_schema.tables WHERE table_name=${tableName}`);
assert.strictEqual(count, 1, `Table not found: ${tableName}`);
}

async function assertTableDoesNotExist(tableName) {
const count = await db.oneFirst(sql`SELECT COUNT(*) FROM information_schema.tables WHERE table_name=${tableName}`);
assert.strictEqual(count, 0, `Table should not exist: ${tableName}`);
}

async function assertTableSchema(tableName, ...expectedCols) {
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
await assertTableExists(tableName);

expectedCols.forEach((def, idx) => {
if(!def.column_name) throw new Error(`Expected column definition is missing required prop: .column_name at index ${idx}`);
});

const actualCols = await db.any(sql`SELECT * FROM information_schema.columns WHERE table_name=${tableName}`);
console.log('actualCols:', actualCols);

assertEqualInAnyOrder(
expectedCols.map(col => col.column_name),
actualCols.map(col => col.column_name),
'Expected columns did not match returned columns!',
);

assertRowsMatch(actualCols, expectedCols);
}

function assertRowsMatch(actualRows, expectedRows) {
assert.strictEqual(actualRows.length, expectedRows.length, 'row count mismatch');

const remainingRows = [...actualRows];
for(let i=0; i<expectedRows.length; ++i) {
const x = expectedRows[i];
let found = false;
for(let j=0; j<remainingRows.length; ++j) {
const rr = remainingRows[j];
try {
assertIncludes(rr, x);
remainingRows.splice(j, 1);
found = true;
break;
} catch(err) { /* keep searching */ }
}
if(!found) {
const filteredRemainingRows = remainingRows.map(r => _.pick(r, Object.keys(x)));
assert.fail(
`Expected row ${i} not found:\njson=` +
JSON.stringify({ remainingRows, filteredRemainingRows, expectedRow:x }),
);
}
}
}

function assertEqualInAnyOrder(a, b, message) {
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
if(!Array.isArray(a)) throw new Error('IllegalArgument: first arg is not an array');
if(!Array.isArray(b)) throw new Error('IllegalArgument: second arg is not an array');
assert.deepEqual([...a].sort(), [...b].sort(), message);
}

function assertIncludes(actual, expected) {
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
for(const [k, expectedVal] of Object.entries(expected)) {
const actualVal = actual[k];
try {
assert.deepEqual(actualVal, expectedVal);
} catch(err) {
assert.fail(`Could not find all properties of ${expected} in ${actual}`);
}
}
}