import test from 'node:test'; import assert from 'node:assert/strict'; import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from '../lib/references.mjs'; const oldUrl = 'https://old.example/admin-api/infra/file/1/get/a.png'; const newUrl = 'https://bucket.example/migrated/a.png'; const otherUrl = 'https://elsewhere.example/a.png'; const replacements = new Map([[oldUrl, newUrl]]); test('replaces an exact URL once and never cascades a destination mapping', () => { assert.deepEqual(rewriteReferences(oldUrl, new Map([[oldUrl, newUrl], [newUrl, otherUrl]])), { value: newUrl, changed: true, replacements: 1 }); assert.deepEqual(rewriteReferences(null, replacements), { value: null, changed: false, replacements: 0 }); }); test('replaces comma lists, repeated values and rich text while preserving surrounding text', () => { const input = `${oldUrl},${otherUrl}, ${oldUrl}\n`; const expected = `${newUrl},${otherUrl}, ${newUrl}\n`; assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 4 }); assert.equal(rewriteReferences(`[image](${oldUrl}),`, replacements).value, `[image](${newUrl}),`); }); test('never matches URL prefixes, query extensions, fragments or external embedded URLs', () => { const unchanged = [ `${oldUrl}.bak`, `${oldUrl}/thumbnail`, `${oldUrl}?width=200`, `${oldUrl}#preview`, `${oldUrl}%20other`, `${oldUrl}&x=1`, `${oldUrl};v=1`, otherUrl, `https://external.example/?redirect=${oldUrl}`, `https://external.example/?redirect=x,${oldUrl}`, `${oldUrl}?url=x,${oldUrl}` ]; for (const value of unchanged) { assert.deepEqual(rewriteReferences(value, replacements), { value, changed: false, replacements: 0 }, value); } }); test('only rewrites queries and fragments explicitly present in the dictionary', () => { const url = `${oldUrl}?v=2#original`; assert.equal(rewriteReferences(``, new Map([[url, newUrl]])).value, ``); assert.equal(rewriteReferences(url, replacements).value, url); }); test('keeps URL encoding literal and supports Unicode paths', () => { const encoded = oldUrl.replace('a.png', '%E5%A4%B4%E5%83%8F%20a.png'); const unicode = oldUrl.replace('a.png', '头像.png'); const mapping = new Map([[encoded, newUrl], [unicode, `${newUrl}?kind=unicode`]]); assert.equal(rewriteReferences(` ${unicode}`, mapping).replacements, 2); assert.equal(rewriteReferences(encoded.replace('%20', ' '), mapping).changed, false); assert.equal(rewriteReferences(encoded.toLowerCase(), mapping).changed, false); }); test('JSON text retains formatting and untouched escapes; keys remain unchanged', () => { const escapedUrl = oldUrl.replaceAll('/', '\\/'); const input = `{\n "${oldUrl}": "key stays", "avatar" : "${escapedUrl}",\n "nested": [{"pic":"${oldUrl}"}], "untouched":"\\u4e2d"\n}`; const expected = `{\n "${oldUrl}": "key stays", "avatar" : "${newUrl}",\n "nested": [{"pic":"${newUrl}"}], "untouched":"\\u4e2d"\n}`; assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 2 }); assert.equal(rewriteReferences(JSON.stringify(JSON.stringify({ avatar: oldUrl })), replacements).value, JSON.stringify(JSON.stringify({ avatar: newUrl }))); }); test('parsed JSON is recursively rewritten without changing input or prototype', () => { const input = JSON.parse(`{"__proto__":{"url":"${oldUrl}"},"photos":["${oldUrl}",null,4],"url":"${otherUrl}"}`); const result = rewriteReferences(input, replacements); assert.equal(result.replacements, 2); assert.equal(input.photos[0], oldUrl); assert.equal(result.value.photos[0], newUrl); assert.equal(result.value.__proto__.url, newUrl); assert.equal(Object.getPrototypeOf(result.value), Object.prototype); assert.equal(rewriteReferences({ url: otherUrl }, replacements).changed, false); }); test('malformed JSON safely uses literal URL replacement without changing escapes', () => { const input = `{"avatar":"${oldUrl}",}`; assert.equal(rewriteReferences(input, replacements).value, `{"avatar":"${newUrl}",}`); }); test('remaining-reference detection finds Unicode-escaped JSON URLs with unknown queries', () => { const withQuery = `${oldUrl}?unmapped=1#preview`; const unicodeJson = JSON.stringify({ avatar: withQuery }).replaceAll('/', '\\u002f'); const rewritten = rewriteReferences(unicodeJson, replacements); assert.equal(rewritten.changed, false); assert.equal(containsLegacyReference(rewritten.value, ['1']), true); assert.equal(containsLegacyReference(rewritten.value, ['2']), false); }); test('remaining-reference detection recursively handles nested JSON strings and parsed values', () => { const nested = JSON.stringify({ data: JSON.stringify({ photos: [`${oldUrl}?v=2`] }).replaceAll('/', '\\u002f') }); assert.equal(containsLegacyReference(nested, new Set([1])), true); assert.equal(containsLegacyReference({ data: [null, { image: oldUrl }] }, [1]), true); assert.equal(containsLegacyReference(JSON.stringify(JSON.stringify(`${oldUrl}#unknown`)), ['1']), true); assert.equal(containsLegacyReference(rewriteReferences(JSON.stringify({ image: oldUrl }), replacements).value, [1]), false); }); test('remaining-reference detection handles escaped plain text, exact config IDs, and excludes JSON keys', () => { assert.equal(containsLegacyReference(``, [1]), true); assert.equal(containsLegacyReference(`${oldUrl}?query=2`, [11]), false); assert.equal(containsLegacyReference(oldUrl.replace('/file/1/', '/file/11/'), [1]), false); assert.equal(containsLegacyReference(JSON.stringify({ [oldUrl]: 'not a URL value' }), [1]), false); assert.equal(containsLegacyReference({ [oldUrl]: 'not a URL value' }, [1]), false); assert.equal(containsLegacyReference(null, [1]), false); assert.equal(containsLegacyReference(oldUrl, []), false); const cycle = {}; cycle.self = cycle; assert.equal(containsLegacyReference(cycle, [1]), false); }); test('discovery binds schema names and excludes unsafe or non-content columns', async () => { const row = (tableName, columnName, extra = {}) => ({ schemaName: 'app', tableName, columnName, dataType: 'varchar', extra: '', generationExpression: '', primaryKey: 'id', primaryKeyCount: 1, ...extra }); const rows = [ row('member_user', 'avatar'), row('member_user', 'id'), row('member_user', 'generated', { generationExpression: 'avatar', extra: 'VIRTUAL GENERATED' }), row('member_user', 'stored', { extra: 'STORED GENERATED' }), row('member_user', 'seq', { extra: 'auto_increment' }), row('member_user', 'defaulted', { extra: 'DEFAULT_GENERATED' }), row('member_com_auth', 'auth_extra_json', { dataType: 'json' }), row('infra_file', 'url'), row('infra_file_content', 'path'), row('infra_file_config', 'config'), row('migration_files', 'old_url'), row('infra_file_migration_journal', 'old_url'), row('infra_file_oss_migration_runs', 'settings'), row('infra_file_oss_migration_items', 'data'), row('infra_file_oss_migration_changes', 'before_json'), row('INFRA_FILE_OSS_MIGRATION_CHANGES', 'after_json'), row('no_key', 'a', { primaryKeyCount: null, primaryKey: null }), row('no_key', 'b', { primaryKeyCount: null, primaryKey: null }), row('composite', 'a', { primaryKeyCount: '2' }), row('skip_me', 'a'), row('skip_qualified', 'a'), ]; const schemas = ['app', 'odd` schema; DROP TABLE x']; const connection = { async execute(sql, params) { assert.deepEqual(params, [...schemas, ...schemas]); assert.ok(!sql.includes(schemas[1])); assert.match(sql, /t\.TABLE_TYPE = 'BASE TABLE'/); assert.match(sql, /c\.DATA_TYPE IN \('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json'\)/); return [rows]; } }; const result = await discoverReferenceColumns(connection, schemas, new Set(['skip_me', 'APP.skip_qualified'])); assert.deepEqual(result.columns, [ { schema: 'app', table: 'member_user', column: 'avatar', primaryKey: 'id', dataType: 'varchar' }, { schema: 'app', table: 'member_user', column: 'defaulted', primaryKey: 'id', dataType: 'varchar' }, { schema: 'app', table: 'member_com_auth', column: 'auth_extra_json', primaryKey: 'id', dataType: 'json' } ]); assert.equal(result.skipped.length, 13); for (const table of ['infra_file_oss_migration_runs', 'infra_file_oss_migration_items', 'infra_file_oss_migration_changes', 'INFRA_FILE_OSS_MIGRATION_CHANGES']) { assert.equal(result.skipped.find((item) => item.table === table).reason, 'excluded table'); assert.ok(!result.columns.some((item) => item.table === table)); } assert.equal(result.skipped.find((item) => item.table === 'no_key').reason, 'no primary key'); assert.equal(result.skipped.find((item) => item.table === 'composite').reason, 'composite primary key'); }); test('discovery makes no connection for an empty schema list and validates names', async () => { assert.deepEqual(await discoverReferenceColumns({}, []), { columns: [], skipped: [] }); await assert.rejects(discoverReferenceColumns({}, ['']), /schema names/); }); test('discovery explicitly excludes known technical and audit logs while retaining business log tables', async () => { const excluded = [ 'infra_api_access_log', 'infra_api_error_log', 'infra_job_log', 'system_operate_log', 'system_login_log', 'SYSTEM_LOGIN_LOG', ]; const retained = ['member_activity_log', 'chat_message_log', 'custom_audit_log', 'infra_api_access_log_archive']; const rows = [...excluded, ...retained].flatMap(tableName => ['content', 'details'].map(columnName => ({ schemaName: 'app', tableName, columnName, dataType: 'text', extra: '', generationExpression: '', primaryKey: 'id', primaryKeyCount: 1, }))); const connection = { async execute() { return [rows]; } }; const result = await discoverReferenceColumns(connection, ['app']); assert.deepEqual(result.skipped, excluded.map(table => ({ schema: 'app', table, reason: 'excluded table' }))); assert.deepEqual([...new Set(result.columns.map(column => column.table))], retained); assert.equal(result.columns.length, retained.length * 2); });