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