178 lines
9.8 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
}
|