Files
Friendship-and-chat-api/script/repair-im-users/test/mysql.test.mjs
T

178 lines
9.8 KiB
JavaScript

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