630 lines
28 KiB
JavaScript
630 lines
28 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
require('../src/common');
|
|
|
|
const { getPendingMigrations } = require('../src/db/migrations');
|
|
|
|
async function getAuthMigrationStep(pool) {
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.1' });
|
|
const migration = pending.find(item => item.version === '2.20.0');
|
|
assert.ok(migration, 'Expected the consolidated 2.20.0 migration');
|
|
return migration;
|
|
}
|
|
|
|
function createPool(responses) {
|
|
const queries = [];
|
|
return {
|
|
queries,
|
|
async query(sql, params) {
|
|
queries.push([sql, params]);
|
|
const text = String(sql);
|
|
for (const response of responses) {
|
|
if (response.match(text, params)) {
|
|
return response.result;
|
|
}
|
|
}
|
|
return [[]];
|
|
}
|
|
};
|
|
}
|
|
|
|
test('role two-factor migration adds an opt-in requirement for existing and fresh schemas', async () => {
|
|
const pool = createPool([]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_roles ADD COLUMN require_two_factor TINYINT(1) NOT NULL DEFAULT 0')));
|
|
const fs = require('node:fs');
|
|
assert.match(fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /require_two_factor TINYINT\(1\) NOT NULL DEFAULT 0/);
|
|
});
|
|
|
|
test('passkey credential storage is defined once in the current schema snapshot', async () => {
|
|
const pool = createPool([]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
const statements = pool.queries.map(([sql]) => String(sql));
|
|
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:user_)?passkeys/.test(sql)), false);
|
|
assert.equal(statements.some(sql => /(?:RENAME TABLE a_passkeys|INSERT IGNORE INTO a_user_passkeys)/.test(sql)), false);
|
|
const fs = require('node:fs');
|
|
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
|
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
|
|
assert.match(schema, /credential_id VARCHAR\(512\) NOT NULL/);
|
|
});
|
|
|
|
test('the consolidated migration does not create and then rename user auth tables', async () => {
|
|
const pool = createPool([]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
const statements = pool.queries.map(([sql]) => String(sql));
|
|
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:two_factors|passkeys)/.test(sql)), false);
|
|
assert.equal(statements.some(sql => /(?:RENAME TABLE a_(?:two_factors|passkeys)|INSERT IGNORE INTO a_user_(?:two_factors|passkeys))/.test(sql)), false);
|
|
const fs = require('node:fs');
|
|
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
|
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
|
|
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
|
|
});
|
|
|
|
test('pending migrations are empty when the schema already matches the app version', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) {
|
|
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
|
},
|
|
result: [[{ column_count: 1 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
}
|
|
]);
|
|
|
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: require('../package.json').version });
|
|
|
|
assert.equal(pendingMigrations.length, 0);
|
|
});
|
|
|
|
test('2.20.1 release follows the consolidated 2.20.0 migration', async () => {
|
|
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' });
|
|
assert.deepEqual(pending.map(migration => migration.version), ['2.20.0', '2.20.1']);
|
|
});
|
|
|
|
test('2.20.1 removes retired security setting rows', async () => {
|
|
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.20.0' });
|
|
const migration = pending.find(item => item.version === '2.20.1');
|
|
assert.ok(migration, 'Expected the 2.20.1 security settings cleanup migration');
|
|
|
|
const pool = createPool([]);
|
|
await migration.run(pool);
|
|
|
|
assert.deepEqual(pool.queries, [[
|
|
'DELETE FROM o_app_settings WHERE setting_key IN (?, ?, ?, ?, ?)',
|
|
[
|
|
'security.session_lifetime_days',
|
|
'security.max_active_sessions',
|
|
'security.login_max_attempts',
|
|
'security.login_lockout_minutes',
|
|
'security.login_rate_limit_scope'
|
|
]
|
|
]]);
|
|
});
|
|
|
|
test('Better Auth migration extends app users and sessions with auth account tables', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('information_schema.COLUMNS') && params && params[0] === 'a_users' && params[1] === 'email_verified_at';
|
|
},
|
|
result: [[{ column_count: 1 }]]
|
|
},
|
|
{
|
|
match(sql) {
|
|
return sql.includes('SELECT id, email, email_verified_at FROM a_users');
|
|
},
|
|
result: [[
|
|
{ id: 1, email: 'first@example.com', email_verified_at: new Date() },
|
|
{ id: 2, email: 'second@example.com', email_verified_at: new Date() },
|
|
{ id: 3, email: 'verified@example.com', email_verified_at: new Date() },
|
|
{ id: 4, email: 'pending@example.com', email_verified_at: null }
|
|
]]
|
|
}
|
|
]);
|
|
const authMigration = await getAuthMigrationStep(pool);
|
|
await authMigration.run(pool);
|
|
|
|
const statements = pool.queries.map(([sql]) => String(sql));
|
|
const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
|
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_accounts/);
|
|
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_verifications/);
|
|
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_accounts')), false);
|
|
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_verifications')), false);
|
|
assert.ok(statements.some(sql => sql.includes('ADD COLUMN email_verified')));
|
|
assert.ok(statements.some(sql => sql.includes('ADD COLUMN last_request')));
|
|
assert.ok(statements.includes('DELETE FROM a_sessions'));
|
|
assert.equal(statements.some(sql => sql.includes('CREATE TRIGGER')), false);
|
|
const emailUpdates = pool.queries
|
|
.filter(([sql]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?'))
|
|
.map(([, params]) => params);
|
|
assert.deepEqual(emailUpdates, [
|
|
['first@example.com', true, 1],
|
|
['second@example.com', true, 2],
|
|
['verified@example.com', true, 3],
|
|
['pending@example.com', false, 4]
|
|
]);
|
|
assert.equal(statements.some(sql => sql.includes('auth_email')), false);
|
|
assert.equal(statements.some(sql => sql.includes('uq_a_users_auth_email')), false);
|
|
assert.equal(statements.some(sql => sql.includes('app-user:%')), false);
|
|
assert.doesNotMatch(schema, /auth_email/);
|
|
});
|
|
|
|
test('consolidated Better Auth migration removes legacy password columns after credential migration', async () => {
|
|
const pool = createPool([
|
|
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] }
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
|
|
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash CHAR(64) NULL')), false);
|
|
});
|
|
|
|
test('Better Auth passwordless migration tolerates fresh schemas without legacy password columns', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
|
|
},
|
|
result: [[{ column_count: 0 }]]
|
|
},
|
|
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash')), false);
|
|
});
|
|
|
|
test('Email OTP migration removes obsolete link and lockout storage only', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
|
result: [[{ column_count: 1 }]]
|
|
},
|
|
{
|
|
match(_sql, params) { return Array.isArray(params) && params[0] === 'a_account_tokens'; },
|
|
result: [[{ table_count: 1 }]]
|
|
},
|
|
{
|
|
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
|
|
result: [[]]
|
|
},
|
|
{
|
|
match() { return true; },
|
|
result: [[{ table_count: 0 }]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
const statements = pool.queries.map(([sql]) => String(sql));
|
|
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_token_hash')));
|
|
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_expires_at')));
|
|
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_at')));
|
|
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_ip')));
|
|
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_failed_at')));
|
|
assert.ok(statements.some(sql => sql.includes('DROP TABLE a_account_tokens')));
|
|
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key IN ('email.verification_button_alignment'")));
|
|
assert.ok(!statements.some(sql => sql.includes('DROP TABLE a_verifications')));
|
|
});
|
|
|
|
test('follow-up migration removes unused admin last-login fields', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
|
result: [[{ column_count: 1 }]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
const alterations = pool.queries.map(([sql]) => String(sql));
|
|
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_at'));
|
|
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_ip'));
|
|
});
|
|
|
|
test('OTP expiry migration consolidates old settings and removes unused session creator', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('SELECT setting_key, setting_value FROM o_app_settings') && params[0] === 'email.otp_expiry_minutes';
|
|
},
|
|
result: [[
|
|
{ setting_key: 'email.verification_expiry_minutes', setting_value: 8 },
|
|
{ setting_key: 'email.reset_expiry_minutes', setting_value: 15 }
|
|
]]
|
|
},
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('information_schema.COLUMNS') && params[0] === 'a_sessions' && params[1] === 'created_by';
|
|
},
|
|
result: [[{ column_count: 1 }]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO o_app_settings') && params[0] === 'email.otp_expiry_minutes' && params[1] === '8'));
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key IN (?, ?)')));
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_sessions DROP COLUMN created_by')));
|
|
});
|
|
|
|
test('per-user password policy migrations remove both old global settings', async () => {
|
|
const pool = createPool([]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
const deletedSettingKeys = pool.queries.filter(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key = ?')).map(([, params]) => params[0]);
|
|
assert.ok(deletedSettingKeys.includes('security.require_password_change_after_admin_reset'));
|
|
assert.ok(deletedSettingKeys.includes('security.require_password_change_for_new_users'));
|
|
});
|
|
|
|
test('canonical email migration backfills synthetic addresses and drops duplicate email fields', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
|
result: [[{ column_count: 1 }]]
|
|
},
|
|
{
|
|
match(sql) {
|
|
return sql.includes('SELECT LOWER(TRIM(email)) AS email_key');
|
|
},
|
|
result: [[]]
|
|
},
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('SELECT id, email, email_verified_at FROM a_users') && params === undefined;
|
|
},
|
|
result: [[
|
|
{ id: 5, email: null, email_verified_at: null },
|
|
{ id: 6, email: 'verified@example.test', email_verified_at: new Date() }
|
|
]]
|
|
},
|
|
{
|
|
match(sql) { return sql.includes('information_schema.STATISTICS'); },
|
|
result: [[{ index_count: 0 }]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
const queries = pool.queries;
|
|
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'app-user-5@identity.invalid' && params[1] === false));
|
|
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'verified@example.test' && params[1] === true));
|
|
assert.equal(queries.some(([sql]) => String(sql).includes('auth_email')), false);
|
|
assert.ok(queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN email_verified_at')));
|
|
});
|
|
|
|
test('canonical email migration refuses duplicate real email addresses', async () => {
|
|
const pool = createPool([
|
|
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
|
|
{
|
|
match: sql => sql.includes('SELECT LOWER(TRIM(email)) AS email_key'),
|
|
result: [[{ email_key: 'duplicate@example.test', duplicate_count: 2 }]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await assert.rejects(migration.run(pool), /duplicate user email addresses/);
|
|
});
|
|
|
|
test('canonical email migration preserves email_verified on fresh schemas', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'email_verified_at';
|
|
},
|
|
result: [[{ column_count: 0 }]]
|
|
},
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'auth_email';
|
|
},
|
|
result: [[{ column_count: 0 }]]
|
|
},
|
|
{
|
|
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
|
result: [[{ column_count: 1 }]]
|
|
},
|
|
{
|
|
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
|
|
result: [[]]
|
|
},
|
|
{
|
|
match(sql) { return sql.includes('SELECT id, email, email_verified FROM a_users'); },
|
|
result: [[{ id: 9, email: 'canonical@example.test', email_verified: 1 }]]
|
|
},
|
|
{
|
|
match(sql, params) { return sql.includes('information_schema.STATISTICS') && Array.isArray(params) && params[1] === 'uq_a_users_auth_email'; },
|
|
result: [[{ index_count: 0 }]]
|
|
},
|
|
{
|
|
match(sql) { return sql.includes("COLUMN_NAME = 'email'"); },
|
|
result: [[{ index_count: 1 }]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'canonical@example.test' && params[1] === true));
|
|
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN email_verified_at')), false);
|
|
});
|
|
|
|
test('credential migration moves existing password records into Better Auth accounts', async () => {
|
|
const passwordHash = 'a'.repeat(64);
|
|
const passwordSalt = 'b'.repeat(32);
|
|
const pool = createPool([
|
|
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
|
|
{
|
|
match: sql => sql.includes('SELECT id, password_hash, password_salt, password_iterations, created_at, modified_at FROM a_users'),
|
|
result: [[
|
|
{ id: 12, password_hash: passwordHash, password_salt: passwordSalt, password_iterations: 310000, created_at: new Date(), modified_at: new Date() },
|
|
{ id: 13, password_hash: null, password_salt: null, password_iterations: null, created_at: new Date(), modified_at: new Date() }
|
|
]]
|
|
}
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[3] === 'pbkdf2-sha256$310000$' + passwordSalt + '$' + passwordHash));
|
|
assert.equal(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[2] === 13), false);
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
|
|
});
|
|
|
|
test('credential migration leaves fresh Better Auth schemas without legacy password columns intact', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql, params) {
|
|
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
|
|
},
|
|
result: [[{ column_count: 0 }]]
|
|
},
|
|
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
|
|
]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
assert.equal(pool.queries.some(([sql]) => String(sql).includes('SELECT id, password_hash, password_salt')), false);
|
|
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash')), false);
|
|
});
|
|
|
|
test('invitation removal migration drops the legacy table, permissions, and settings', async () => {
|
|
const pool = createPool([]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
const statements = pool.queries.map(([sql]) => String(sql));
|
|
assert.ok(statements.includes('DROP TABLE IF EXISTS a_user_invitations'));
|
|
assert.ok(statements.some(sql => sql.includes('DELETE FROM a_role_permissions') && sql.includes("permission_key LIKE 'invitations.%'")));
|
|
assert.ok(statements.some(sql => sql.includes("DELETE FROM a_permissions WHERE permission_key LIKE 'invitations.%'")));
|
|
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key LIKE 'email.invitation_%'")));
|
|
});
|
|
|
|
test('Better Auth two-factor migration adds user state while the table comes from the schema snapshot', async () => {
|
|
const pool = createPool([]);
|
|
const migration = await getAuthMigrationStep(pool);
|
|
await migration.run(pool);
|
|
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ADD COLUMN two_factor_enabled')));
|
|
assert.equal(pool.queries.some(([sql]) => String(sql).includes('CREATE TABLE IF NOT EXISTS a_two_factors')), false);
|
|
assert.match(require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
|
|
});
|
|
|
|
test('query index migration upgrades a 2.17.4 installation', async () => {
|
|
const queries = [];
|
|
const pool = {
|
|
queries,
|
|
async query(sql, params) {
|
|
queries.push([sql, params]);
|
|
if (String(sql).includes('FROM information_schema.STATISTICS')) {
|
|
return [[{ index_count: params[1] === 'idx_background_tasks_status' ? 1 : 0 }]];
|
|
}
|
|
return [[]];
|
|
}
|
|
};
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.4' });
|
|
const indexMigration = pending.find(migration => migration.version === '2.17.5');
|
|
|
|
assert.ok(indexMigration);
|
|
await indexMigration.run(pool);
|
|
|
|
const alterations = queries.map(([sql]) => String(sql)).filter(sql => sql.startsWith('ALTER TABLE'));
|
|
assert.equal(alterations.length, 9);
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_playlist_slides_playlist_position')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_template_regions_template_z')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_slides_template')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_templates_canvas_size')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_sessions_expires_at')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_background_tasks_status_created_at')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_rss_feeds_modified_at')));
|
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_api_source_groups_modified_at')));
|
|
assert.ok(alterations.some(sql => sql.includes('DROP INDEX idx_background_tasks_status')));
|
|
});
|
|
|
|
test('playlist shuffle migration upgrades an existing 2.15.0 installation', async () => {
|
|
const pool = createPool([]);
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.0' });
|
|
const shuffleMigration = pending.find(migration => migration.version === '2.15.1');
|
|
assert.ok(shuffleMigration);
|
|
await shuffleMigration.run(pool);
|
|
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_shuffle')));
|
|
});
|
|
|
|
test('playlist repeat migration upgrades an existing 2.15.1 installation', async () => {
|
|
const pool = createPool([]);
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.1' });
|
|
const repeatMigration = pending.find(migration => migration.version === '2.15.2');
|
|
assert.ok(repeatMigration);
|
|
await repeatMigration.run(pool);
|
|
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_mode')));
|
|
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_repeat_count')));
|
|
});
|
|
|
|
test('canvas size enabled migration upgrades an existing 2.15.2 installation', async () => {
|
|
const pool = createPool([]);
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.2' });
|
|
const canvasMigration = pending.find(migration => migration.version === '2.15.3');
|
|
assert.ok(canvasMigration);
|
|
await canvasMigration.run(pool);
|
|
assert.ok(pool.queries.some(([sql]) => sql.includes('ALTER TABLE c_canvas_sizes ADD COLUMN enabled')));
|
|
});
|
|
|
|
test('playlist group schema migration is pending for version 2.14.3', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) {
|
|
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
|
},
|
|
result: [[{ column_count: 1 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
}
|
|
]);
|
|
|
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.14.3' });
|
|
|
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.15.0'; }));
|
|
});
|
|
|
|
test('pending migrations are reported when an older schema still needs scripts', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) {
|
|
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
|
},
|
|
result: [[{ column_count: 0 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
|
},
|
|
result: [[{ table_count: 1 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
|
},
|
|
result: [[{ table_count: 1 }]]
|
|
}
|
|
]);
|
|
|
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
|
|
|
|
assert.ok(pendingMigrations.length > 0);
|
|
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 4);
|
|
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
|
|
});
|
|
|
|
test('a partial timetable schema does not fast-forward migration detection', async () => {
|
|
const pool = createPool([
|
|
{
|
|
match(sql) {
|
|
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
|
},
|
|
result: [[{ column_count: 0 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_timetable_groups';
|
|
},
|
|
result: [[{ table_count: 1 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_timetable_entries';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
},
|
|
{
|
|
match(_sql, params) {
|
|
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
|
},
|
|
result: [[{ table_count: 0 }]]
|
|
}
|
|
]);
|
|
|
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '0.0.0' });
|
|
|
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; }));
|
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; }));
|
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; }));
|
|
});
|
|
|
|
test('template region defaults migration upgrades a 2.17.5 installation', async () => {
|
|
const pool = createPool([]);
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.5' });
|
|
const regionContentMigration = pending.find(migration => migration.version === '2.18.0');
|
|
|
|
assert.ok(regionContentMigration);
|
|
await regionContentMigration.run(pool);
|
|
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN default_content_json JSON NULL')));
|
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0')));
|
|
});
|
|
|
|
test('2.19.0 indexed-reference migration backfills slide and template defaults', async () => {
|
|
const queries = [];
|
|
const insertedReferences = [];
|
|
const pool = {
|
|
queries,
|
|
async query(sql, params) {
|
|
const text = String(sql);
|
|
queries.push([text, params]);
|
|
if (text.includes('FROM information_schema.COLUMNS')) return [[{ column_count: 1 }]];
|
|
if (text.includes('FROM information_schema.TABLES')) return [[{ table_count: 0 }]];
|
|
if (text.startsWith('SELECT id, content_json FROM c_slides')) {
|
|
return Number(params[0]) === 0
|
|
? [[{ id: 2, content_json: JSON.stringify({ hero: { type: 'image', value: '/media/uploads/hero.png' } }) }]]
|
|
: [[]];
|
|
}
|
|
if (text.startsWith('SELECT id, template_id, region_key, default_content_json FROM c_template_regions')) {
|
|
return Number(params[0]) === 0
|
|
? [[{ id: 7, template_id: 4, region_key: 'headline', default_content_json: { type: 'text', font_family: 'Acme Display' } }]]
|
|
: [[]];
|
|
}
|
|
if (text.startsWith('INSERT IGNORE INTO c_content_references')) {
|
|
insertedReferences.push.apply(insertedReferences, params[0]);
|
|
}
|
|
return [[]];
|
|
}
|
|
};
|
|
|
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.18.0' });
|
|
const migration = pending.find(item => item.version === '2.19.0');
|
|
assert.ok(migration);
|
|
await migration.run(pool);
|
|
|
|
assert.ok(queries.some(([sql]) => sql.includes('CREATE TABLE IF NOT EXISTS c_content_references')));
|
|
assert.ok(queries.some(([sql]) => sql.includes('id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY')));
|
|
assert.ok(queries.some(([sql]) => sql.includes('UNIQUE KEY uq_content_reference_owner')));
|
|
assert.ok(queries.every(([sql]) => !sql.includes('idx_content_reference_owner')));
|
|
assert.ok(insertedReferences.some(row => row[0] === 'slide' && row[1] === 2 && row[3] === 'media' && row[4] === '/media/uploads/hero.png'));
|
|
assert.ok(insertedReferences.some(row => row[0] === 'template' && row[1] === 4 && row[2] === 'headline' && row[3] === 'font-family' && row[4] === 'acme display'));
|
|
}); |