Files
pulse-signage/test/schema-update-log.test.js
T
lzstealth b71958cb38
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m21s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
Release 2.22.0: normalize playlist schedules and unify player sync
2026-10-27 22:54:38 +00:00

746 lines
34 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.filter(migration => migration.version.startsWith('2.20.')).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'));
});
function createPlaylistScheduleMigrationPool(groupRows, sourceRuleCount) {
const queries = [];
const legacyGroupColumns = new Set([
'group_key', 'group_name', 'group_pick_count', 'group_shuffle',
'group_mode', 'group_repeat_count', 'group_schedule_rules_json'
]);
let hasGroupId = false;
let hasLegacyScheduleTable = true;
let migratedSlideRuleCount = 0;
const groupInsertParams = [];
const groupRuleInsertParams = [];
const memberGroupUpdates = [];
return {
queries,
groupInsertParams,
groupRuleInsertParams,
memberGroupUpdates,
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('FROM information_schema.COLUMNS')) {
const columnName = params[1];
const exists = columnName === 'group_id' ? hasGroupId : legacyGroupColumns.has(columnName);
return [[{ column_count: exists ? 1 : 0 }]];
}
if (text.includes('FROM information_schema.TABLES')) {
return [[{ table_count: params[0] === 'c_playlist_slide_schedule_rules' && hasLegacyScheduleTable ? 1 : 0 }]];
}
if (text.includes('FROM information_schema.KEY_COLUMN_USAGE')) {
return [[{ fk_count: 0 }]];
}
if (text.startsWith('ALTER TABLE c_playlist_slides ADD COLUMN group_id')) {
hasGroupId = true;
}
if (text.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')) {
text.split('DROP COLUMN ').slice(1).forEach(part => legacyGroupColumns.delete(part.split(/[ ,]/)[0]));
}
if (text.includes('SELECT id, playlist_id, group_key, group_name')) {
return [structuredClone(groupRows)];
}
if (text.startsWith('INSERT INTO c_playlist_groups')) {
groupInsertParams.push(params);
return [{ insertId: 77 }];
}
if (text.startsWith('INSERT INTO c_playlist_schedule_rules') && text.includes('VALUES')) {
groupRuleInsertParams.push(params);
}
if (text.startsWith('UPDATE c_playlist_slides SET group_id')) {
memberGroupUpdates.push(params);
}
if (text.startsWith('SELECT COUNT(*) AS rule_count FROM c_playlist_slide_schedule_rules')) {
return [[{ rule_count: sourceRuleCount }]];
}
if (text.startsWith('DELETE FROM c_playlist_schedule_rules WHERE playlist_slide_id')) {
migratedSlideRuleCount = 0;
}
if (text.includes('INSERT INTO c_playlist_schedule_rules') && text.includes('SELECT playlist_slide_id')) {
migratedSlideRuleCount = sourceRuleCount;
}
if (text.startsWith('SELECT COUNT(*) AS rule_count FROM c_playlist_schedule_rules WHERE playlist_slide_id')) {
return [[{ rule_count: migratedSlideRuleCount }]];
}
if (text === 'DROP TABLE c_playlist_slide_schedule_rules') {
hasLegacyScheduleTable = false;
}
return [[]];
}
};
}
function buildLegacyPlaylistGroupRows(groupRulesJson, secondPickCount) {
return [
{ id: 501, playlist_id: 8, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 1, group_mode: 'select', group_repeat_count: 1, group_schedule_rules_json: groupRulesJson, created_at: '2026-10-01 10:00:00', created_by: 4, modified_at: '2026-10-02 10:00:00', modified_by: 5 },
{ id: 502, playlist_id: 8, group_key: 'events', group_name: 'Events', group_pick_count: secondPickCount || 1, group_shuffle: 1, group_mode: 'select', group_repeat_count: 1, group_schedule_rules_json: groupRulesJson, created_at: '2026-10-01 10:00:00', created_by: 4, modified_at: '2026-10-02 10:00:00', modified_by: 5 }
];
}
test('2.22.0 playlist schedule migration consolidates shared groups and copies both rule owners', async () => {
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.21.6' });
const migration = pending.find(item => item.version === '2.22.0');
assert.ok(migration);
const groupRules = JSON.stringify([{ start_datetime: '2026-10-01T10:00', days: [2] }]);
const pool = createPlaylistScheduleMigrationPool(buildLegacyPlaylistGroupRows(groupRules), 2);
await migration.run(pool);
assert.equal(pool.groupInsertParams.length, 1);
assert.deepEqual(pool.groupInsertParams[0].slice(0, 7), [8, 'events', 'Events', 1, 1, 'select', 1]);
assert.equal(pool.memberGroupUpdates.length, 2);
assert.deepEqual(pool.memberGroupUpdates, [[77, 501], [77, 502]]);
assert.deepEqual(pool.groupRuleInsertParams, [[77, 0, '2026-10-01T10:00', null, null, null, '[2]', 5, 5]]);
assert.ok(pool.queries.some(([sql]) => sql.includes('INSERT INTO c_playlist_schedule_rules') && sql.includes('SELECT playlist_slide_id')));
assert.ok(pool.queries.some(([sql]) => sql === 'DROP TABLE c_playlist_slide_schedule_rules'));
const legacyGroupColumnDrops = pool.queries.filter(([sql]) => sql.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN'));
assert.equal(legacyGroupColumnDrops.length, 1);
assert.match(legacyGroupColumnDrops[0][0], /DROP COLUMN group_key, DROP COLUMN group_name, DROP COLUMN group_pick_count, DROP COLUMN group_shuffle, DROP COLUMN group_mode, DROP COLUMN group_repeat_count, DROP COLUMN group_schedule_rules_json/);
const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS c_playlist_groups/);
assert.match(schema, /CREATE TABLE IF NOT EXISTS c_playlist_schedule_rules/);
assert.doesNotMatch(schema, /group_schedule_rules_json/);
});
test('2.22.0 playlist schedule migration aborts before dropping inconsistent legacy group data', async () => {
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.21.6' });
const migration = pending.find(item => item.version === '2.22.0');
assert.ok(migration);
const pool = createPlaylistScheduleMigrationPool(buildLegacyPlaylistGroupRows('[]', 2), 0);
await assert.rejects(migration.run(pool), /inconsistent settings across members/);
assert.equal(pool.groupInsertParams.length, 0);
assert.equal(pool.queries.some(([sql]) => sql === 'DROP TABLE c_playlist_slide_schedule_rules'), false);
assert.equal(pool.queries.some(([sql]) => sql.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')), false);
});