kt-template-online-api/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts

526 lines
24 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'],
];
/**
* Advances SQL single-quote state at one character, respecting SQL doubled
* quotes and MySQL backslash escaping. An odd number of preceding backslashes
* escapes a quote; an even number lets it open or close a string.
*
* @param sql SQL source being scanned.
* @param index Index of the current character in {@link sql}.
* @param quoted Whether the scanner is currently inside a single-quoted value.
* @returns The next quote state and whether the following doubled quote is consumed.
*/
const advanceSqlQuoteState = (sql: string, index: number, quoted: boolean) => {
if (sql[index] !== "'") return { quoted, skipNext: false };
let precedingBackslashes = 0;
for (let cursor = index - 1; sql[cursor] === '\\'; cursor -= 1) precedingBackslashes += 1;
if (precedingBackslashes % 2 === 1) return { quoted, skipNext: false };
if (quoted && sql[index + 1] === "'") return { quoted, skipNext: true };
return {
quoted: !quoted,
skipNext: false,
};
};
/**
* Normalizes SQL syntax while preserving the exact casing and whitespace inside
* single-quoted contract values.
*/
const normalizeSql = (sql: string) => {
let normalized = '';
let quoted = false;
let previousWasWhitespace = false;
for (let index = 0; index < sql.length; index += 1) {
const character = sql[index];
if (quoted) {
normalized += character;
const quoteState = advanceSqlQuoteState(sql, index, quoted);
if (quoteState.skipNext) {
normalized += sql[index + 1];
index += 1;
}
quoted = quoteState.quoted;
previousWasWhitespace = false;
continue;
}
if (character === "'") {
quoted = advanceSqlQuoteState(sql, index, quoted).quoted;
normalized += character;
previousWasWhitespace = false;
continue;
}
if (character === '`') continue;
if (/\s/.test(character)) {
if (!previousWasWhitespace) normalized += ' ';
previousWasWhitespace = true;
continue;
}
normalized += character.toLowerCase();
previousWasWhitespace = false;
}
return normalized.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];
const quoteState = advanceSqlQuoteState(tuple, index, quoted);
if (quoteState.skipNext) {
current += tuple[index + 1];
index += 1;
continue;
}
quoted = quoteState.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];
const quoteState = advanceSqlQuoteState(values, index, quoted);
if (quoteState.skipNext) {
current += values[index + 1];
index += 1;
continue;
}
quoted = quoteState.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 every top-level VALUES row from the message-push admin-menu 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);
};
/** Extracts the statement that grants the seeded message-push menus to active roles. */
const extractMessagePushMenuRoleGrantStatement = (sql: string) => {
const statements = splitSqlStatements(sql);
const menuSeedIndex = statements.findIndex((statement) =>
statement.startsWith('insert into admin_menu') && statement.includes(menuEntries[0][0]));
expect(menuSeedIndex).toBeGreaterThanOrEqual(0);
const roleGrantIndex = statements.findIndex((statement, index) =>
index > menuSeedIndex && statement.startsWith('insert ignore into admin_role_menu'));
expect(roleGrantIndex).toBeGreaterThan(menuSeedIndex);
return statements[roleGrantIndex] || '';
};
/** Extracts the explicit stable menu IDs from a menu-role grant statement. */
const extractMenuRoleGrantIds = (statement: string) => {
const ids = statement.match(/menu\.id in \(([^)]*)\)/)?.[1].match(/\d+/g) || [];
return ids;
};
/** Splits SQL statements only at semicolons outside single-quoted values. */
const splitSqlStatements = (sql: string) => {
const statements: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < sql.length; index += 1) {
const character = sql[index];
current += character;
const quoteState = advanceSqlQuoteState(sql, index, quoted);
if (quoteState.skipNext) {
current += sql[index + 1];
index += 1;
continue;
}
quoted = quoteState.quoted;
if (character === ';' && !quoted) {
statements.push(current.trim());
current = '';
}
}
if (current.trim()) statements.push(current.trim());
return statements;
};
/** Locates one complete verification statement by its stable check-name literal. */
const extractVerificationStatement = (sql: string, checkName: string) => {
const statement = splitSqlStatements(sql).find((candidate) => candidate.includes(`'${checkName}'`));
expect(statement).toBeTruthy();
return statement || '';
};
/** Extracts the body of the expected_menu CTE without crossing quoted literals. */
const extractExpectedMenuCte = (statement: string) => {
const cteStart = statement.match(/with expected_menu as \(/);
expect(cteStart).toBeTruthy();
const openIndex = (cteStart?.index || 0) + (cteStart?.[0].length || 0) - 1;
let depth = 0;
let quoted = false;
for (let index = openIndex; index < statement.length; index += 1) {
const character = statement[index];
const quoteState = advanceSqlQuoteState(statement, index, quoted);
if (quoteState.skipNext) {
index += 1;
continue;
}
quoted = quoteState.quoted;
if (quoted) continue;
if (character === '(') depth += 1;
if (character === ')') {
depth -= 1;
if (depth === 0) return statement.slice(openIndex + 1, index);
}
}
throw new Error('expected_menu CTE is not closed');
};
/** Extracts exact five-column expected-menu tuples from the mismatch CTE. */
const extractMismatchMenuRows = (statement: string): MenuEntry[] => {
const cte = extractExpectedMenuCte(statement);
return [...cte.matchAll(/select (\d+)(?: as id)?, (\d+)(?: as pid)?, '((?:''|[^'])*)'(?: as name)?, '((?:''|[^'])*)'(?: as auth_code)?, (\d+)(?: as sort)?/g)]
.map(([, id, pid, name, authCode, sort]) => [id, pid, name.replace(/''/g, "'"), authCode.replace(/''/g, "'"), sort] as MenuEntry);
};
/** Extracts exact expected-menu IDs from a cardinality or role-grant CTE. */
const extractExpectedMenuIds = (statement: string) =>
[...extractExpectedMenuCte(statement).matchAll(/select (\d+)(?: as id)?/g)].map(([, id]) => 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('normalizes only SQL syntax and preserves single-quoted contract values exactly', () => {
expect(normalizeSql("SELECT `AUTH_CODE`, 'QqBot:MessageTemplate:List', '${{endpoint}}'"))
.toBe("select auth_code, 'QqBot:MessageTemplate:List', '${{endpoint}}'");
});
it('recognizes SQL quote endings, doubled quotes, and odd or even backslashes in every scanner', () => {
expect(splitSqlTuple("'closed', next")).toEqual(["'closed'", 'next']);
expect(splitSqlTuple("'it''s closed', next")).toEqual(["'it's closed'", 'next']);
expect(splitSqlTuple(String.raw`'odd\' quote', next`)).toEqual([String.raw`'odd\' quote'`, 'next']);
const oddEscapedQuoteThenEnd = String.raw`'odd\''`;
expect(splitSqlTuple(`${oddEscapedQuoteThenEnd}, next`)).toEqual([oddEscapedQuoteThenEnd, 'next']);
expect(extractSqlTuples(`(${oddEscapedQuoteThenEnd}), ('next')`)).toEqual([
oddEscapedQuoteThenEnd,
"'next'",
]);
expect(splitSqlStatements(`select ${oddEscapedQuoteThenEnd}; select 'next';`)).toEqual([
`select ${oddEscapedQuoteThenEnd};`,
"select 'next';",
]);
const cteStatements = splitSqlStatements(
`with expected_menu as (select ${oddEscapedQuoteThenEnd} as id); select 'next';`,
);
expect(cteStatements).toHaveLength(2);
expect(extractExpectedMenuCte(cteStatements[0])).toBe(`select ${oddEscapedQuoteThenEnd} as id`);
expect(splitSqlTuple(String.raw`'even\\', next`)).toEqual([String.raw`'even\\'`, 'next']);
expect(extractSqlTuples(String.raw`('even\\'), ('next')`)).toEqual([String.raw`'even\\'`, "'next'"]);
expect(splitSqlStatements(String.raw`select 'even\\'; select 'next';`)).toEqual([
String.raw`select 'even\\';`,
"select 'next';",
]);
});
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('verifies the default template stable values with case-sensitive text comparisons', () => {
const defaultTemplateVerify = extractVerificationStatement(verifySql, 'seed_qqbot_message_template');
expect(defaultTemplateVerify).toBe(normalizeSql(`
select 'seed_qqbot_message_template' as check_name, count(*) as matched_rows
from qqbot_message_template
where id = 2041700000000200601
and binary name = binary 'STUN 映射端口变更默认模板'
and binary source_key = binary 'network.stun.mapping-port-changed'
and binary content = binary '当前STUN的端口已变更为\${{endpoint}}'
and enabled = 1
and is_deleted = 0;
`));
});
it('verifies every message-push table with its own complete row-count statement', () => {
for (const table of Object.keys(messagePushTableDefinitions)) {
const statement = extractVerificationStatement(verifySql, table);
expect(statement).toBe(`select '${table}' as table_name, count(*) as row_count from ${table};`);
}
});
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);
}
});
it('grants only the seeded message-push menus to active super and admin roles after every menu seed', () => {
for (const sql of [bootstrapSql, seedSql, vbenSql]) {
const roleGrant = extractMessagePushMenuRoleGrantStatement(sql);
expect(roleGrant).toContain('insert ignore into admin_role_menu (role_id, menu_id)');
expect(extractMenuRoleGrantIds(roleGrant)).toEqual(menuEntries.map(([id]) => id));
expect(roleGrant).toContain("role.role_code in ('super', 'admin')");
expect(roleGrant).toContain('role.status = 1');
expect(roleGrant).toContain('role.is_deleted = 0');
expect(roleGrant).toContain('menu.status = 1');
expect(roleGrant).toContain('menu.is_deleted = 0');
}
});
it('uses an exact mismatch CTE and null-safe predicates for every stable menu tuple', () => {
const mismatch = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_mismatch');
expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries);
expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id');
expect(mismatch).toContain('not (binary actual.name <=> binary expected.name)');
expect(mismatch).toContain('not (binary actual.auth_code <=> binary expected.auth_code)');
expect(mismatch).toContain('actual.pid <> expected.pid');
expect(mismatch).toContain('actual.sort <> expected.sort');
expect(mismatch).toContain('actual.status <> 1');
expect(mismatch).toContain('actual.is_deleted <> 0');
});
it('uses an exact cardinality CTE with expected, actual, and missing counts', () => {
const cardinality = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_cardinality');
expect(extractExpectedMenuIds(cardinality)).toEqual(menuEntries.map(([id]) => id));
expect(cardinality).toContain('left join admin_menu actual on actual.id = expected.id');
expect(cardinality).toContain('count(*) as expected_count');
expect(cardinality).toContain('count(actual.id) as actual_count');
expect(cardinality).toContain('count(*) - count(actual.id) as missing_count');
});
it('uses an exact role-grant CTE for enabled super and admin roles', () => {
const roleGrant = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_role_grant_missing');
expect(extractExpectedMenuIds(roleGrant)).toEqual(menuEntries.map(([id]) => id));
expect(roleGrant).toContain('from admin_role role cross join expected_menu expected');
expect(roleGrant).toContain('left join admin_role_menu role_menu');
expect(roleGrant).toContain("role.role_code in ('super', 'admin')");
expect(roleGrant).toContain('role.status = 1');
expect(roleGrant).toContain('role.is_deleted = 0');
expect(roleGrant).toContain('role_menu.menu_id is null');
});
it('extracts the three message-push verification CTEs as separate statements', () => {
const checkNames = splitSqlStatements(verifySql)
.filter((statement) => statement.includes('seed_qqbot_message_push_menu_'))
.map((statement) => statement.match(/'([^']+)' as check_name/)?.[1]);
expect(checkNames).toEqual([
'seed_qqbot_message_push_menu_mismatch',
'seed_qqbot_message_push_menu_cardinality',
'seed_qqbot_message_push_menu_role_grant_missing',
]);
});
});