import test from 'node:test'; import assert from 'node:assert/strict'; import mysql from 'mysql2/promise'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; import { main } from '../repair-im-users.mjs'; const ports = (process.env.CHAT_REPAIR_TEST_PORTS ?? '').split(',').filter(Boolean).map(Number); const apiRoot = fileURLToPath(new URL('../../../', import.meta.url)); const imRoot = resolve(apiRoot, '../Friendship-and-chat-im/box-im-master'); process.env.CHAT_REPAIR_FIXTURE_PASSWORD = 'chat_sync_test'; async function fixture(port) { const token = randomUUID().replaceAll('-', ''); const businessName = `codex_chat_business_${token}`; const imName = `codex_chat_im_${token}`; const base = { host: '127.0.0.1', port, user: 'root', password: 'chat_sync_test', supportBigNumbers: true, bigNumberStrings: true, multipleStatements: true }; const admin = await mysql.createConnection(base); const directory = await mkdtemp(resolve(tmpdir(), 'chat-repair-test-')); const config = resolve(directory, 'config.json'); const plan = resolve(directory, 'plan.json'); await admin.query(`CREATE DATABASE ${businessName} CHARACTER SET utf8mb4; CREATE DATABASE ${imName} CHARACTER SET utf8mb4`); const business = await mysql.createConnection({ ...base, database: businessName }); const im = await mysql.createConnection({ ...base, database: imName }); try { await business.query(`CREATE TABLE member_user ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 1, nickname VARCHAR(30) NOT NULL DEFAULT '', avatar VARCHAR(255), mobile VARCHAR(11), status TINYINT NOT NULL DEFAULT 0, deleted BIT(1) NOT NULL DEFAULT b'0') ENGINE=InnoDB; CREATE TABLE infra_job (id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32), status TINYINT, handler_name VARCHAR(64),handler_param VARCHAR(255),cron_expression VARCHAR(32),retry_count INT, retry_interval INT,monitor_timeout INT,deleted BIT(1) DEFAULT b'0');`); await business.query(await readFile(resolve(apiRoot, 'sql/mysql/member/member_chat_sync.sql'), 'utf8')); await im.query(await readFile(resolve(imRoot, 'db/im-platform.sql'), 'utf8')); await business.query(`INSERT INTO member_user (id,nickname,status,deleted) VALUES (100,'老芋头',1,b'1'),(131,'老芋头',0,b'0'),(501,'新会员',0,b'0')`); await im.query(`INSERT INTO im_user (id,user_name,nick_name,password) VALUES (-100,'消息通知','消息通知','retained'),(-200,'客服','客服','retained'), (100,'u131','旧100','retained'),(131,'u100','旧131','retained'),(900,'孤立账号','孤立账号','retained'); INSERT INTO im_friend (id,user_id,friend_id,friend_nick_name,friend_head_image) VALUES (1,100,131,'旧131',''); INSERT INTO im_group (id,owner_id,name) VALUES (1,100,'原群'); INSERT INTO im_group_member (id,group_id,user_id,user_nick_name,remark_nick_name,head_image) VALUES (1,1,131,'旧131','原备注',''); INSERT INTO im_private_message (send_id,recv_id,content,type,status) VALUES (100,131,'原消息',0,3);`); const connection = database => ({ host: '127.0.0.1', port, user: 'root', database, passwordEnv: 'CHAT_REPAIR_FIXTURE_PASSWORD' }); await writeFile(config, JSON.stringify({ business: connection(businessName), im: connection(imName) })); return { business, im, config, plan, run: (command, ...extra) => main([command, '--config', config, '--plan', plan, ...extra]), async close() { await business.end(); await im.end(); await admin.query(`DROP DATABASE ${businessName}; DROP DATABASE ${imName}`); await admin.end(); await rm(directory, { recursive: true, force: true }); }, }; } catch (error) { await business.end(); await im.end(); await admin.query(`DROP DATABASE IF EXISTS ${businessName}; DROP DATABASE IF EXISTS ${imName}`); await admin.end(); await rm(directory, { recursive: true, force: true }); throw error; } } test('real MySQL repair integration requires explicit isolated localhost ports', { skip: ports.length !== 0 }, () => {}); for (const port of ports) { test(`MySQL ${port} prechecks field capacity, actual collation and transactional storage`, async () => { const context = await fixture(port); try { await context.im.query('ALTER TABLE im_user MODIFY head_image VARCHAR(4)'); await context.business.query("UPDATE member_user SET avatar='too-long' WHERE id=131"); await assert.rejects(context.run('plan', '--out', context.plan), /head_image.*长度/); await context.business.query("UPDATE member_user SET avatar='' WHERE id=131"); await context.im.query('ALTER TABLE im_user MODIFY user_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL'); await context.im.query("UPDATE im_user SET user_name='u501' WHERE id=-100"); await assert.rejects(context.run('plan', '--out', context.plan), /排序规则/); await context.im.query("UPDATE im_user SET user_name='消息通知' WHERE id=-100"); await context.im.query('ALTER TABLE im_friend ENGINE=MyISAM'); await assert.rejects(context.run('plan', '--out', context.plan), /InnoDB/); } finally { await context.close(); } }); test(`MySQL ${port} repairs cyclic names, repeats safely and rolls back`, async () => { const context = await fixture(port); try { await context.run('plan', '--out', context.plan); await context.run('apply', '--maintenance'); await context.run('apply', '--maintenance'); await context.run('verify'); const [users] = await context.im.query('SELECT id,user_name,member_state,auth_version FROM im_user WHERE id > 0 ORDER BY id'); assert.deepEqual(users.map(user => [user.id, user.user_name, user.member_state]), [['100', 'u100', 2], ['131', 'u131', 0], ['900', 'u900', 3]]); const [[counter]] = await context.business.query('SELECT last_id FROM member_chat_sync_sequence WHERE id=1'); assert.equal(String(counter.last_id), '900'); await assert.rejects(context.run('verify', '--ready'), /尚未完成|待同步/); await context.im.query("UPDATE im_user SET nick_name='后续修改' WHERE id=131"); await assert.rejects(context.run('rollback', '--maintenance'), /清单不一致/); await context.im.query("UPDATE im_user SET nick_name='老芋头' WHERE id=131"); await context.run('rollback', '--maintenance'); await context.run('rollback', '--maintenance'); const [[restored]] = await context.im.query('SELECT user_name,auth_version FROM im_user WHERE id=100'); assert.equal(restored.user_name, 'u131'); assert.ok(BigInt(restored.auth_version) > BigInt(users[0].auth_version)); const [[message]] = await context.im.query('SELECT COUNT(*) AS n FROM im_private_message'); assert.equal(Number(message.n), 1); } finally { await context.close(); } }); test(`MySQL ${port} resumes after lost commit acknowledgement`, async () => { const context = await fixture(port); try { await context.run('plan', '--out', context.plan); await context.run('apply', '--maintenance'); const journalPath = `${context.plan}.journal.json`; const journal = JSON.parse(await readFile(journalPath, 'utf8')); journal.phase = 'imApplying'; await writeFile(journalPath, JSON.stringify(journal)); await context.run('apply', '--maintenance'); await context.run('verify'); } finally { await context.close(); } }); test(`MySQL ${port} verifies readiness after missing users and initialization complete`, async () => { const context = await fixture(port); try { await context.run('plan', '--out', context.plan); await context.run('apply', '--maintenance'); const plan = JSON.parse(await readFile(context.plan, 'utf8')); const missing = plan.taskChanges.find(task => task.member_id === '501'); const desired = JSON.parse(missing.profile_json); await context.im.query('INSERT INTO im_user (id,user_name,nick_name,password,head_image,head_image_thumb,member_state,sync_version) VALUES (?,?,?,?,?,?,?,?)', ['501', desired.userName, desired.nickName, 'new-random-password-hash', desired.avatar, desired.avatar, desired.memberState, missing.version]); await context.business.query("UPDATE member_chat_sync SET synced_version=version,initialized=b'1'"); await context.im.query("INSERT INTO im_friend (id,user_id,friend_id,friend_nick_name) VALUES (2,501,-200,'客服')"); await context.im.query("INSERT INTO im_private_message (send_id,recv_id,content,type,status) VALUES (-200,501,'欢迎',0,0)"); await context.run('verify', '--ready'); const report = JSON.parse(await readFile(`${context.plan}.verification.json`, 'utf8')); assert.equal(report.verified, true); assert.equal(report.pending, 0); assert.equal(report.pendingInitialization, 0); } finally { await context.close(); } }); test(`MySQL ${port} rejects database identity and concurrent source changes`, async () => { const context = await fixture(port); try { await context.run('plan', '--out', context.plan); await context.business.query("UPDATE member_user SET nickname='后续修改' WHERE id=131"); await assert.rejects(context.run('apply', '--maintenance'), /清单不一致/); const [[user]] = await context.im.query('SELECT user_name FROM im_user WHERE id=100'); assert.equal(user.user_name, 'u131'); const config = JSON.parse(await readFile(context.config, 'utf8')); [config.business.database, config.im.database] = [config.im.database, config.business.database]; await writeFile(context.config, JSON.stringify(config)); await assert.rejects(context.run('apply', '--maintenance')); } finally { await context.close(); } }); }