286 lines
14 KiB
TypeScript
286 lines
14 KiB
TypeScript
import { readFileSync } from 'fs';
|
|
import { resolve } from 'path';
|
|
|
|
type MenuEntry = readonly [
|
|
id: string,
|
|
pid: string,
|
|
name: string,
|
|
authCode: string,
|
|
sort: string,
|
|
];
|
|
|
|
const messagePushTableDefinitions = {
|
|
qqbot_message_subscription: {
|
|
columns: [
|
|
'id bigint not null', 'name varchar(100) not null',
|
|
'source_key varchar(128) not null', 'source_config json not null',
|
|
'source_config_digest char(64) not null', 'active_key varchar(255) null',
|
|
'enabled tinyint(1) not null default 1', 'remark varchar(500) null',
|
|
'is_deleted tinyint(1) not null default 0',
|
|
'create_time datetime(6) not null default current_timestamp(6)',
|
|
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
|
|
],
|
|
indexes: ['primary key (id)', 'unique key uk_qqbot_message_subscription_active_key (active_key)'],
|
|
},
|
|
qqbot_message_template: {
|
|
columns: [
|
|
'id bigint not null', 'name varchar(100) not null',
|
|
'source_key varchar(128) not null', 'content text not null',
|
|
'enabled tinyint(1) not null default 1', 'remark varchar(500) null',
|
|
'is_deleted tinyint(1) not null default 0',
|
|
'create_time datetime(6) not null default current_timestamp(6)',
|
|
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
|
|
],
|
|
indexes: ['primary key (id)'],
|
|
},
|
|
qqbot_message_publish_binding: {
|
|
columns: [
|
|
'id bigint not null', 'subscription_id bigint not null',
|
|
'account_id bigint not null', 'self_id varchar(64) not null',
|
|
'template_id bigint not null', 'active_key varchar(255) null',
|
|
'enabled tinyint(1) not null default 1', 'is_deleted tinyint(1) not null default 0',
|
|
'create_time datetime(6) not null default current_timestamp(6)',
|
|
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
|
|
],
|
|
indexes: ['primary key (id)', 'unique key uk_qqbot_message_publish_binding_active_key (active_key)'],
|
|
},
|
|
qqbot_message_publish_target: {
|
|
columns: [
|
|
'id bigint not null', 'binding_id bigint not null',
|
|
'target_type varchar(16) not null', 'target_id varchar(64) not null',
|
|
'target_name varchar(120) null', 'active_key varchar(300) null',
|
|
'enabled tinyint(1) not null default 1', 'is_deleted tinyint(1) not null default 0',
|
|
'create_time datetime(6) not null default current_timestamp(6)',
|
|
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
|
|
],
|
|
indexes: ['primary key (id)', 'unique key uk_qqbot_message_publish_target_active_key (active_key)'],
|
|
},
|
|
qqbot_message_event: {
|
|
columns: [
|
|
'id bigint not null', 'event_id varchar(128) not null',
|
|
'source_key varchar(128) not null', 'resource_key varchar(128) not null',
|
|
'occurred_at datetime(6) not null', 'payload json not null',
|
|
"fanout_status varchar(32) not null default 'accepted'", 'fanout_attempt_count int unsigned not null default 0',
|
|
'next_fanout_at datetime(6) null', 'fanout_lease_until datetime(6) null',
|
|
'last_error_code varchar(64) null', 'last_error_message varchar(500) null',
|
|
'create_time datetime(6) not null default current_timestamp(6)',
|
|
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
|
|
],
|
|
indexes: [
|
|
'primary key (id)', 'unique key uk_qqbot_message_event_event_id (event_id)',
|
|
'key idx_qqbot_message_event_dispatch (fanout_status, next_fanout_at)',
|
|
'key idx_qqbot_message_event_lease (fanout_lease_until)',
|
|
],
|
|
},
|
|
qqbot_message_delivery: {
|
|
columns: [
|
|
'id bigint not null', 'message_event_id bigint not null',
|
|
'publish_target_id bigint not null', 'binding_id bigint not null',
|
|
'subscription_id bigint not null', 'self_id varchar(64) not null',
|
|
'target_type varchar(16) not null', 'target_id varchar(64) not null',
|
|
'template_id bigint not null', 'template_content text not null',
|
|
'variable_snapshot json not null', 'rendered_message text not null',
|
|
'status varchar(32) not null', 'attempt_count int unsigned not null default 0',
|
|
'next_attempt_at datetime(6) null', 'processing_lease_until datetime(6) null',
|
|
'send_log_id bigint null', 'last_error_code varchar(64) null',
|
|
'last_error_message varchar(500) null', 'expires_at datetime(6) not null',
|
|
'create_time datetime(6) not null default current_timestamp(6)',
|
|
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
|
|
],
|
|
indexes: [
|
|
'primary key (id)', 'unique key uk_qqbot_message_delivery_event_target (message_event_id, publish_target_id)',
|
|
'key idx_qqbot_message_delivery_dispatch (status, next_attempt_at)',
|
|
'key idx_qqbot_message_delivery_lease (processing_lease_until)',
|
|
'key idx_qqbot_message_delivery_history (subscription_id, message_event_id)',
|
|
],
|
|
},
|
|
} as const;
|
|
|
|
const menuEntries: readonly MenuEntry[] = [
|
|
['2041700000000100413', '2041700000000100400', 'QqBotMessageSubscription', 'QqBot:MessageSubscription:List', '10'],
|
|
['2041700000000100414', '2041700000000100400', 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List', '11'],
|
|
['2041700000000120461', '2041700000000100413', 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List', '0'],
|
|
['2041700000000120462', '2041700000000100413', 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create', '0'],
|
|
['2041700000000120463', '2041700000000100413', 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update', '0'],
|
|
['2041700000000120464', '2041700000000100413', 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete', '0'],
|
|
['2041700000000120465', '2041700000000100413', 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle', '0'],
|
|
['2041700000000120471', '2041700000000100414', 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List', '0'],
|
|
['2041700000000120472', '2041700000000100414', 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create', '0'],
|
|
['2041700000000120473', '2041700000000100414', 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update', '0'],
|
|
['2041700000000120474', '2041700000000100414', 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete', '0'],
|
|
['2041700000000120475', '2041700000000100414', 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle', '0'],
|
|
['2041700000000120476', '2041700000000100414', 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview', '0'],
|
|
['2041700000000120481', '2041700000000100410', 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List', '0'],
|
|
['2041700000000120482', '2041700000000100410', 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create', '0'],
|
|
['2041700000000120483', '2041700000000100410', 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update', '0'],
|
|
['2041700000000120484', '2041700000000100410', 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete', '0'],
|
|
['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'],
|
|
];
|
|
|
|
/** Normalizes SQL formatting while preserving the exact statement content. */
|
|
const normalizeSql = (sql: string) => sql
|
|
.toLowerCase()
|
|
.replace(/`/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
/** Reads a repository SQL file as normalized text. */
|
|
const readNormalizedSql = (relativePath: string) =>
|
|
normalizeSql(readFileSync(resolve(process.cwd(), relativePath), 'utf8'));
|
|
|
|
/** Extracts one CREATE TABLE block so assertions cannot match another table. */
|
|
const extractCreateTableBlock = (sql: string, table: string) => {
|
|
const escapedTable = table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const match = sql.match(new RegExp(
|
|
`create table if not exists ${escapedTable} \\((.*?)\\) engine=innodb default charset=utf8mb4 collate=utf8mb4_unicode_ci;`,
|
|
's',
|
|
));
|
|
expect(match).toBeTruthy();
|
|
return match?.[0] || '';
|
|
};
|
|
|
|
/** Splits a SQL VALUES tuple without treating quoted JSON or text commas as separators. */
|
|
const splitSqlTuple = (tuple: string) => {
|
|
const values: string[] = [];
|
|
let current = '';
|
|
let quoted = false;
|
|
for (let index = 0; index < tuple.length; index += 1) {
|
|
const character = tuple[index];
|
|
if (character === "'" && tuple[index - 1] !== '\\') quoted = !quoted;
|
|
if (character === ',' && !quoted) {
|
|
values.push(current.trim());
|
|
current = '';
|
|
continue;
|
|
}
|
|
current += character;
|
|
}
|
|
values.push(current.trim());
|
|
return values;
|
|
};
|
|
|
|
/** Removes SQL string delimiters from one already-split VALUES field. */
|
|
const unquoteSqlValue = (value: string) => value.replace(/^'|'$/g, '');
|
|
|
|
/** Extracts top-level SQL VALUES tuples while preserving quoted payloads. */
|
|
const extractSqlTuples = (values: string) => {
|
|
const tuples: string[] = [];
|
|
let current = '';
|
|
let depth = 0;
|
|
let quoted = false;
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
const character = values[index];
|
|
if (character === "'" && values[index - 1] !== '\\') quoted = !quoted;
|
|
if (!quoted && character === '(') {
|
|
depth += 1;
|
|
if (depth === 1) current = '';
|
|
else current += character;
|
|
continue;
|
|
}
|
|
if (!quoted && character === ')') {
|
|
depth -= 1;
|
|
if (depth === 0) tuples.push(current);
|
|
else current += character;
|
|
continue;
|
|
}
|
|
if (depth > 0) current += character;
|
|
}
|
|
return tuples;
|
|
};
|
|
|
|
/** Extracts the message-push menu VALUES rows from the matching seed statement. */
|
|
const extractMessagePushMenuRows = (sql: string) => {
|
|
const statements = [...sql.matchAll(/insert into admin_menu \([^)]*\) values (.*?) on duplicate key update/gs)];
|
|
const statement = statements.find((candidate) => candidate[1].includes(menuEntries[0][0]));
|
|
expect(statement).toBeTruthy();
|
|
const values = statement?.[1] || '';
|
|
const tuples = extractSqlTuples(values);
|
|
return tuples
|
|
.map(splitSqlTuple)
|
|
.map((fields) => [
|
|
unquoteSqlValue(fields[0]), unquoteSqlValue(fields[1]),
|
|
unquoteSqlValue(fields[2]), unquoteSqlValue(fields[6]),
|
|
unquoteSqlValue(fields[10]),
|
|
] as MenuEntry)
|
|
.filter(([id]) => menuEntries.some(([expectedId]) => expectedId === id));
|
|
};
|
|
|
|
describe('QQBot message-push SQL contract', () => {
|
|
const bootstrapSql = readNormalizedSql('sql/qqbot-init.sql');
|
|
const schemaSql = readNormalizedSql('sql/refactor-v3/00-full-schema.sql');
|
|
const seedSql = readNormalizedSql('sql/refactor-v3/01-seed-core.sql');
|
|
const verifySql = readNormalizedSql('sql/refactor-v3/99-verify.sql');
|
|
const vbenSql = readNormalizedSql('sql/vben-admin-init.sql');
|
|
|
|
it('keeps each table DDL and index tuple exact in current and refactor schemas', () => {
|
|
for (const [table, expected] of Object.entries(messagePushTableDefinitions)) {
|
|
for (const sql of [bootstrapSql, schemaSql]) {
|
|
const block = extractCreateTableBlock(sql, table);
|
|
let expectedColumns = sql === bootstrapSql
|
|
? expected.columns.map((column) => column.endsWith(' null') && !column.endsWith('not null')
|
|
? column.replace(/ null$/, ' default null')
|
|
: column)
|
|
: expected.columns;
|
|
const expectedIndexes = [...expected.indexes];
|
|
if (sql === schemaSql) {
|
|
expectedColumns = expectedColumns.map((column, index) => index === 0
|
|
? `${column} primary key`
|
|
: column);
|
|
expectedIndexes.shift();
|
|
}
|
|
const expectedBlock = normalizeSql([
|
|
`create table if not exists ${table} (`,
|
|
[...expectedColumns, ...expectedIndexes].join(', '),
|
|
') engine=innodb default charset=utf8mb4 collate=utf8mb4_unicode_ci;',
|
|
].join(' '));
|
|
expect(block).toBe(expectedBlock);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('seeds the exact default template natural key, content, stable ID, and absence guard', () => {
|
|
const expectedTemplate = normalizeSql(`
|
|
insert into qqbot_message_template (id, name, source_key, content, enabled, remark, is_deleted)
|
|
select 2041700000000200601, 'STUN 映射端口变更默认模板',
|
|
'network.stun.mapping-port-changed', '当前STUN的端口已变更为\${{endpoint}}', 1, '系统默认模板', 0
|
|
where not exists (
|
|
select 1 from qqbot_message_template
|
|
where source_key = 'network.stun.mapping-port-changed'
|
|
and name = 'STUN 映射端口变更默认模板' and is_deleted = 0
|
|
);
|
|
`);
|
|
for (const sql of [bootstrapSql, seedSql]) expect(sql).toContain(expectedTemplate);
|
|
});
|
|
|
|
it('keeps every stable menu tuple together and exactly once in every seed file', () => {
|
|
for (const sql of [bootstrapSql, seedSql, vbenSql]) {
|
|
const rows = extractMessagePushMenuRows(sql);
|
|
expect(rows).toHaveLength(18);
|
|
expect(new Set(rows.map(([id]) => id)).size).toBe(18);
|
|
expect(rows).toEqual(menuEntries.map((entry) => entry.map((value) => value.toLowerCase())));
|
|
}
|
|
});
|
|
|
|
it('uses an inline exact menu expected set to verify rows, cardinality, and enabled-role grants', () => {
|
|
for (const [index, [id, pid, name, authCode, sort]] of menuEntries.entries()) {
|
|
expect(verifySql).toContain(normalizeSql(
|
|
`${index === 0 ? 'select' : 'union all select'} ${id}${index === 0 ? ' as id' : ''}, ${pid}${index === 0 ? ' as pid' : ''}, '${name}'${index === 0 ? ' as name' : ''}, '${authCode}'${index === 0 ? ' as auth_code' : ''}, ${sort}${index === 0 ? ' as sort' : ''}`,
|
|
));
|
|
}
|
|
expect(verifySql).toContain('left join admin_menu actual on actual.id = expected.id');
|
|
expect(verifySql).toContain('actual.name <> expected.name');
|
|
expect(verifySql).toContain('actual.auth_code <> expected.auth_code');
|
|
expect(verifySql).toContain('actual.pid <> expected.pid');
|
|
expect(verifySql).toContain('actual.sort <> expected.sort');
|
|
expect(verifySql).toContain('actual.status <> 1');
|
|
expect(verifySql).toContain('actual.is_deleted <> 0');
|
|
expect(verifySql).toContain('count(*) as expected_count');
|
|
expect(verifySql).toContain('count(actual.id) as actual_count');
|
|
expect(verifySql).toContain('from admin_role role cross join expected_menu expected');
|
|
expect(verifySql).toContain('left join admin_role_menu role_menu');
|
|
expect(verifySql).toContain("role.role_code in ('super', 'admin')");
|
|
expect(verifySql).toContain('role.status = 1');
|
|
expect(verifySql).toContain('role.is_deleted = 0');
|
|
expect(verifySql).toContain('role_menu.menu_id is null');
|
|
});
|
|
});
|