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')); });