fix: 收紧QQBot消息推送持久化校验
This commit is contained in:
parent
5b34a4d8b1
commit
a019137c16
@ -38,13 +38,73 @@ WHERE id = 2041700000000200601
|
||||
AND enabled = 1
|
||||
AND is_deleted = 0;
|
||||
|
||||
SELECT 'seed_qqbot_message_push_menu' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM admin_menu
|
||||
WHERE auth_code IN (
|
||||
'QqBot:MessageSubscription:List', 'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Update', 'QqBot:MessageSubscription:Delete', 'QqBot:MessageSubscription:Toggle',
|
||||
'QqBot:MessageTemplate:List', 'QqBot:MessageTemplate:Create', 'QqBot:MessageTemplate:Update', 'QqBot:MessageTemplate:Delete', 'QqBot:MessageTemplate:Toggle', 'QqBot:MessageTemplate:Preview',
|
||||
'QqBot:Account:MessagePush:List', 'QqBot:Account:MessagePush:Create', 'QqBot:Account:MessagePush:Update', 'QqBot:Account:MessagePush:Delete', 'QqBot:Account:MessagePush:Toggle'
|
||||
) AND status = 1 AND is_deleted = 0;
|
||||
WITH expected_menu AS (
|
||||
SELECT 2041700000000100413 AS id, 2041700000000100400 AS pid, 'QqBotMessageSubscription' AS name, 'QqBot:MessageSubscription:List' AS auth_code, 10 AS sort
|
||||
UNION ALL SELECT 2041700000000100414, 2041700000000100400, 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List', 11
|
||||
UNION ALL SELECT 2041700000000120461, 2041700000000100413, 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List', 0
|
||||
UNION ALL SELECT 2041700000000120462, 2041700000000100413, 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create', 0
|
||||
UNION ALL SELECT 2041700000000120463, 2041700000000100413, 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update', 0
|
||||
UNION ALL SELECT 2041700000000120464, 2041700000000100413, 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete', 0
|
||||
UNION ALL SELECT 2041700000000120465, 2041700000000100413, 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle', 0
|
||||
UNION ALL SELECT 2041700000000120471, 2041700000000100414, 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List', 0
|
||||
UNION ALL SELECT 2041700000000120472, 2041700000000100414, 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create', 0
|
||||
UNION ALL SELECT 2041700000000120473, 2041700000000100414, 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update', 0
|
||||
UNION ALL SELECT 2041700000000120474, 2041700000000100414, 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete', 0
|
||||
UNION ALL SELECT 2041700000000120475, 2041700000000100414, 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle', 0
|
||||
UNION ALL SELECT 2041700000000120476, 2041700000000100414, 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview', 0
|
||||
UNION ALL SELECT 2041700000000120481, 2041700000000100410, 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List', 0
|
||||
UNION ALL SELECT 2041700000000120482, 2041700000000100410, 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create', 0
|
||||
UNION ALL SELECT 2041700000000120483, 2041700000000100410, 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update', 0
|
||||
UNION ALL SELECT 2041700000000120484, 2041700000000100410, 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete', 0
|
||||
UNION ALL SELECT 2041700000000120485, 2041700000000100410, 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', 0
|
||||
)
|
||||
SELECT 'seed_qqbot_message_push_menu_mismatch' AS check_name,
|
||||
expected.id, expected.pid, expected.name, expected.auth_code, expected.sort,
|
||||
actual.id AS actual_id, actual.pid AS actual_pid, actual.name AS actual_name,
|
||||
actual.auth_code AS actual_auth_code, actual.sort AS actual_sort,
|
||||
actual.status AS actual_status, actual.is_deleted AS actual_is_deleted
|
||||
FROM expected_menu expected
|
||||
LEFT JOIN admin_menu actual ON actual.id = expected.id
|
||||
WHERE actual.id IS NULL
|
||||
OR actual.name <> expected.name
|
||||
OR actual.auth_code <> expected.auth_code
|
||||
OR actual.pid <> expected.pid
|
||||
OR actual.sort <> expected.sort
|
||||
OR actual.status <> 1
|
||||
OR actual.is_deleted <> 0;
|
||||
|
||||
WITH expected_menu AS (
|
||||
SELECT 2041700000000100413 AS id UNION ALL SELECT 2041700000000100414 UNION ALL SELECT 2041700000000120461
|
||||
UNION ALL SELECT 2041700000000120462 UNION ALL SELECT 2041700000000120463 UNION ALL SELECT 2041700000000120464
|
||||
UNION ALL SELECT 2041700000000120465 UNION ALL SELECT 2041700000000120471 UNION ALL SELECT 2041700000000120472
|
||||
UNION ALL SELECT 2041700000000120473 UNION ALL SELECT 2041700000000120474 UNION ALL SELECT 2041700000000120475
|
||||
UNION ALL SELECT 2041700000000120476 UNION ALL SELECT 2041700000000120481 UNION ALL SELECT 2041700000000120482
|
||||
UNION ALL SELECT 2041700000000120483 UNION ALL SELECT 2041700000000120484 UNION ALL SELECT 2041700000000120485
|
||||
)
|
||||
SELECT 'seed_qqbot_message_push_menu_cardinality' AS check_name,
|
||||
COUNT(*) AS expected_count, COUNT(actual.id) AS actual_count,
|
||||
COUNT(*) - COUNT(actual.id) AS missing_count
|
||||
FROM expected_menu expected
|
||||
LEFT JOIN admin_menu actual ON actual.id = expected.id
|
||||
AND actual.status = 1
|
||||
AND actual.is_deleted = 0;
|
||||
|
||||
WITH expected_menu AS (
|
||||
SELECT 2041700000000100413 AS id UNION ALL SELECT 2041700000000100414 UNION ALL SELECT 2041700000000120461
|
||||
UNION ALL SELECT 2041700000000120462 UNION ALL SELECT 2041700000000120463 UNION ALL SELECT 2041700000000120464
|
||||
UNION ALL SELECT 2041700000000120465 UNION ALL SELECT 2041700000000120471 UNION ALL SELECT 2041700000000120472
|
||||
UNION ALL SELECT 2041700000000120473 UNION ALL SELECT 2041700000000120474 UNION ALL SELECT 2041700000000120475
|
||||
UNION ALL SELECT 2041700000000120476 UNION ALL SELECT 2041700000000120481 UNION ALL SELECT 2041700000000120482
|
||||
UNION ALL SELECT 2041700000000120483 UNION ALL SELECT 2041700000000120484 UNION ALL SELECT 2041700000000120485
|
||||
)
|
||||
SELECT 'seed_qqbot_message_push_menu_role_grant_missing' AS check_name,
|
||||
role.role_code, expected.id AS menu_id
|
||||
FROM admin_role role CROSS JOIN expected_menu expected
|
||||
LEFT JOIN admin_role_menu role_menu ON role_menu.role_id = role.id AND role_menu.menu_id = expected.id
|
||||
WHERE role.role_code IN ('super', 'admin')
|
||||
AND role.status = 1
|
||||
AND role.is_deleted = 0
|
||||
AND role_menu.menu_id IS NULL;
|
||||
|
||||
SELECT 'qqbot_napcat_webui_gateway_audit table exists' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM information_schema.tables
|
||||
|
||||
@ -25,18 +25,62 @@ export type SystemMessageDeliveryReadiness =
|
||||
|
||||
export interface SystemMessageSourceAdapter {
|
||||
readonly definition: SystemMessageSourceDefinition;
|
||||
/**
|
||||
* 检查订阅配置当前是否仍可用。
|
||||
* @param config - 待检查的来源配置。
|
||||
* @returns 有效性、稳定错误码与可展示的来源摘要;不写入状态。
|
||||
*/
|
||||
inspectSubscription(config: Record<string, unknown>): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>;
|
||||
/**
|
||||
* 列出创建或编辑订阅时可选择的来源资源。
|
||||
* @returns 来源专属的候选项快照;不创建订阅或事务状态。
|
||||
*/
|
||||
listSubscriptionOptions(): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* 规范化并验证用户提交的订阅配置。
|
||||
* @param input - 未信任的订阅配置输入。
|
||||
* @returns 可持久化的规范配置、资源键及来源摘要;调用方在事务中保存返回值。
|
||||
*/
|
||||
normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record<string, string>; resourceKey: string; sourceSummary: string; }>;
|
||||
/**
|
||||
* 根据事件和订阅配置重新计算一次投递就绪状态。
|
||||
* @param input - 冻结的事件载荷及当前订阅配置。
|
||||
* @returns 可发送变量、等待 DDNS 或取消/被取代状态;不直接发送消息。
|
||||
*/
|
||||
resolveDelivery(input: { eventPayload: Record<string, SystemMessageScalar>; subscriptionConfig: Record<string, unknown>; }): Promise<SystemMessageDeliveryReadiness>;
|
||||
/**
|
||||
* 校验并收窄生产者事件载荷至允许的标量值。
|
||||
* @param payload - 来自系统事件的未验证载荷。
|
||||
* @returns 可安全写入 Outbox 的规范化标量载荷;非法输入抛出稳定契约错误。
|
||||
*/
|
||||
validateEventPayload(payload: Record<string, unknown>): Record<string, SystemMessageScalar>;
|
||||
}
|
||||
|
||||
export interface SystemMessageEventInput { eventId: string; occurredAt: string; payload: Record<string, SystemMessageScalar>; resourceKey: string; sourceKey: string; }
|
||||
export const SYSTEM_MESSAGE_EVENT_STAGER = Symbol('SYSTEM_MESSAGE_EVENT_STAGER');
|
||||
export interface SystemMessageEventStager { stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>; }
|
||||
export interface SystemMessageEventStager {
|
||||
/**
|
||||
* 在调用方事务中幂等写入一条系统消息 Outbox 事件。
|
||||
* @param manager - 当前事务的实体管理器;不得替换为独立连接。
|
||||
* @param input - 已验证的事件标识、来源、资源和标量载荷。
|
||||
* @returns 新事件为 `accepted`,相同事件 ID 已存在时为 `duplicate`;不在事务内唤醒或发送消息。
|
||||
*/
|
||||
stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>;
|
||||
}
|
||||
export const SYSTEM_MESSAGE_DELIVERY_COORDINATOR = Symbol('SYSTEM_MESSAGE_DELIVERY_COORDINATOR');
|
||||
export interface SystemMessageDeliveryCoordinator { notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise<void>; requestDrain(): void; }
|
||||
export interface SystemMessageDeliveryCoordinator {
|
||||
/**
|
||||
* 在 DDNS 同步提交后通知协调器重新评估等待中的投递。
|
||||
* @param input - 已应用地址及对应 DDNS 记录 ID。
|
||||
* @returns 唤醒请求已被接收;实现应在提交后异步合并 drain,不在调用事务内发送消息。
|
||||
*/
|
||||
notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise<void>;
|
||||
/**
|
||||
* 请求一次可合并的异步投递 drain。
|
||||
* @returns 无返回值;实现会安排 worker 唤醒,不保证本调用完成任何发送。
|
||||
*/
|
||||
requestDrain(): void;
|
||||
}
|
||||
|
||||
export interface MessageSubscriptionListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; }
|
||||
export interface MessageSubscriptionInput { enabled: boolean; name: string; remark?: string; sourceConfig: Record<string, unknown>; sourceKey: string; }
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import {
|
||||
QQBOT_CORE_ENTITIES,
|
||||
} from '../../../../src/modules/qqbot/core/qqbot-core.module';
|
||||
import { QQBOT_CORE_ENTITIES } from '../../../../src/modules/qqbot/core/qqbot-core.module';
|
||||
import { QqbotMessageDelivery } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity';
|
||||
import { QqbotMessageEvent } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity';
|
||||
import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||
@ -9,75 +7,193 @@ import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/in
|
||||
import { QqbotMessageSubscription } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||
import { QqbotMessageTemplate } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||
|
||||
const messagePushEntities = [
|
||||
QqbotMessageSubscription,
|
||||
QqbotMessageTemplate,
|
||||
QqbotMessagePublishBinding,
|
||||
QqbotMessagePublishTarget,
|
||||
QqbotMessageEvent,
|
||||
QqbotMessageDelivery,
|
||||
type EntityClass = new (...args: never[]) => unknown;
|
||||
type ColumnContract = {
|
||||
default: boolean | number | string | null;
|
||||
length: number | null;
|
||||
name: string;
|
||||
nullable: boolean;
|
||||
precision: number | null;
|
||||
primary: boolean;
|
||||
propertyName: string;
|
||||
type: string;
|
||||
unsigned: boolean;
|
||||
};
|
||||
|
||||
/** Builds an expected varchar metadata tuple without consulting production contracts. */
|
||||
const varchar = (propertyName: string, length: number, nullable = false): ColumnContract => ({ default: null, length, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable, precision: null, primary: false, propertyName, type: 'varchar', unsigned: false });
|
||||
/** Builds an expected bigint metadata tuple without consulting production contracts. */
|
||||
const bigint = (propertyName: string, nullable = false, primary = false): ColumnContract => ({ default: null, length: null, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable, precision: null, primary, propertyName, type: 'bigint', unsigned: false });
|
||||
/** Builds an expected datetime(6) metadata tuple without consulting production contracts. */
|
||||
const datetime = (propertyName: string, nullable = false): ColumnContract => ({ default: null, length: null, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable, precision: 6, primary: false, propertyName, type: 'datetime', unsigned: false });
|
||||
/** Builds an expected tinyint boolean metadata tuple without consulting production contracts. */
|
||||
const boolean = (propertyName: string, defaultValue: boolean): ColumnContract => ({ default: defaultValue, length: null, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable: false, precision: null, primary: false, propertyName, type: 'tinyint', unsigned: false });
|
||||
|
||||
const persistenceContract: ReadonlyArray<{
|
||||
columns: readonly ColumnContract[];
|
||||
entity: EntityClass;
|
||||
indexes: ReadonlyArray<{ columns: readonly string[]; name: string; unique: boolean }>;
|
||||
table: string;
|
||||
}> = [
|
||||
{
|
||||
entity: QqbotMessageSubscription,
|
||||
table: 'qqbot_message_subscription',
|
||||
columns: [
|
||||
bigint('id', false, true), varchar('name', 100), varchar('sourceKey', 128),
|
||||
{ ...varchar('sourceConfig', 0), length: null, type: 'json' },
|
||||
{ ...varchar('sourceConfigDigest', 64), type: 'char' }, varchar('activeKey', 255, true),
|
||||
boolean('enabled', true), varchar('remark', 500, true), boolean('isDeleted', false),
|
||||
datetime('createTime'), datetime('updateTime'),
|
||||
],
|
||||
indexes: [{ name: 'uk_qqbot_message_subscription_active_key', columns: ['activeKey'], unique: true }],
|
||||
},
|
||||
{
|
||||
entity: QqbotMessageTemplate,
|
||||
table: 'qqbot_message_template',
|
||||
columns: [
|
||||
bigint('id', false, true), varchar('name', 100), varchar('sourceKey', 128),
|
||||
{ ...varchar('content', 0), length: null, type: 'text' }, boolean('enabled', true),
|
||||
varchar('remark', 500, true), boolean('isDeleted', false), datetime('createTime'), datetime('updateTime'),
|
||||
],
|
||||
indexes: [],
|
||||
},
|
||||
{
|
||||
entity: QqbotMessagePublishBinding,
|
||||
table: 'qqbot_message_publish_binding',
|
||||
columns: [
|
||||
bigint('id', false, true), bigint('subscriptionId'), bigint('accountId'), varchar('selfId', 64),
|
||||
bigint('templateId'), varchar('activeKey', 255, true), boolean('enabled', true),
|
||||
boolean('isDeleted', false), datetime('createTime'), datetime('updateTime'),
|
||||
],
|
||||
indexes: [{ name: 'uk_qqbot_message_publish_binding_active_key', columns: ['activeKey'], unique: true }],
|
||||
},
|
||||
{
|
||||
entity: QqbotMessagePublishTarget,
|
||||
table: 'qqbot_message_publish_target',
|
||||
columns: [
|
||||
bigint('id', false, true), bigint('bindingId'), varchar('targetType', 16), varchar('targetId', 64),
|
||||
varchar('targetName', 120, true), varchar('activeKey', 300, true), boolean('enabled', true),
|
||||
boolean('isDeleted', false), datetime('createTime'), datetime('updateTime'),
|
||||
],
|
||||
indexes: [{ name: 'uk_qqbot_message_publish_target_active_key', columns: ['activeKey'], unique: true }],
|
||||
},
|
||||
{
|
||||
entity: QqbotMessageEvent,
|
||||
table: 'qqbot_message_event',
|
||||
columns: [
|
||||
bigint('id', false, true), varchar('eventId', 128), varchar('sourceKey', 128), varchar('resourceKey', 128),
|
||||
datetime('occurredAt'), { ...varchar('payload', 0), length: null, type: 'json' },
|
||||
{ ...varchar('fanoutStatus', 32), default: 'accepted' },
|
||||
{ ...varchar('fanoutAttemptCount', 0), default: 0, length: null, type: 'int', unsigned: true },
|
||||
datetime('nextFanoutAt', true), datetime('fanoutLeaseUntil', true), varchar('lastErrorCode', 64, true),
|
||||
varchar('lastErrorMessage', 500, true), datetime('createTime'), datetime('updateTime'),
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'uk_qqbot_message_event_event_id', columns: ['eventId'], unique: true },
|
||||
{ name: 'idx_qqbot_message_event_dispatch', columns: ['fanoutStatus', 'nextFanoutAt'], unique: false },
|
||||
{ name: 'idx_qqbot_message_event_lease', columns: ['fanoutLeaseUntil'], unique: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
entity: QqbotMessageDelivery,
|
||||
table: 'qqbot_message_delivery',
|
||||
columns: [
|
||||
bigint('id', false, true), bigint('messageEventId'), bigint('publishTargetId'), bigint('bindingId'),
|
||||
bigint('subscriptionId'), varchar('selfId', 64), varchar('targetType', 16), varchar('targetId', 64),
|
||||
bigint('templateId'), { ...varchar('templateContent', 0), length: null, type: 'text' },
|
||||
{ ...varchar('variableSnapshot', 0), length: null, type: 'json' }, { ...varchar('renderedMessage', 0), length: null, type: 'text' },
|
||||
varchar('status', 32), { ...varchar('attemptCount', 0), default: 0, length: null, type: 'int', unsigned: true },
|
||||
datetime('nextAttemptAt', true), datetime('processingLeaseUntil', true), bigint('sendLogId', true),
|
||||
varchar('lastErrorCode', 64, true), varchar('lastErrorMessage', 500, true), datetime('expiresAt'),
|
||||
datetime('createTime'), datetime('updateTime'),
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'uk_qqbot_message_delivery_event_target', columns: ['messageEventId', 'publishTargetId'], unique: true },
|
||||
{ name: 'idx_qqbot_message_delivery_dispatch', columns: ['status', 'nextAttemptAt'], unique: false },
|
||||
{ name: 'idx_qqbot_message_delivery_lease', columns: ['processingLeaseUntil'], unique: false },
|
||||
{ name: 'idx_qqbot_message_delivery_history', columns: ['subscriptionId', 'messageEventId'], unique: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedColumns = {
|
||||
qqbot_message_delivery: [
|
||||
'id', 'message_event_id', 'publish_target_id', 'binding_id',
|
||||
'subscription_id', 'self_id', 'target_type', 'target_id', 'template_id',
|
||||
'template_content', 'variable_snapshot', 'rendered_message', 'status',
|
||||
'attempt_count', 'next_attempt_at', 'processing_lease_until',
|
||||
'send_log_id', 'last_error_code', 'last_error_message', 'expires_at',
|
||||
'create_time', 'update_time',
|
||||
],
|
||||
qqbot_message_event: [
|
||||
'id', 'event_id', 'source_key', 'resource_key', 'occurred_at', 'payload',
|
||||
'fanout_status', 'fanout_attempt_count', 'next_fanout_at',
|
||||
'fanout_lease_until', 'last_error_code', 'last_error_message',
|
||||
'create_time', 'update_time',
|
||||
],
|
||||
qqbot_message_publish_binding: [
|
||||
'id', 'subscription_id', 'account_id', 'self_id', 'template_id',
|
||||
'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time',
|
||||
],
|
||||
qqbot_message_publish_target: [
|
||||
'id', 'binding_id', 'target_type', 'target_id', 'target_name',
|
||||
'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time',
|
||||
],
|
||||
qqbot_message_subscription: [
|
||||
'id', 'name', 'source_key', 'source_config', 'source_config_digest',
|
||||
'active_key', 'enabled', 'remark', 'is_deleted', 'create_time',
|
||||
'update_time',
|
||||
],
|
||||
qqbot_message_template: [
|
||||
'id', 'name', 'source_key', 'content', 'enabled', 'remark',
|
||||
'is_deleted', 'create_time', 'update_time',
|
||||
],
|
||||
} as const;
|
||||
/** Converts TypeORM's constructor shorthand into its SQL-facing type name. */
|
||||
const normalizeColumnType = (type: unknown) => {
|
||||
if (type === String) return 'varchar';
|
||||
if (type === Boolean) return 'tinyint';
|
||||
return `${type}`.toLowerCase();
|
||||
};
|
||||
|
||||
/** Reads the table name declared by a TypeORM entity. */
|
||||
const tableNameOf = (entity: object) =>
|
||||
getMetadataArgsStorage().tables.find((table) => table.target === entity)
|
||||
?.name;
|
||||
/** Reads an entity's TypeORM column metadata into a stable comparison tuple. */
|
||||
const getColumns = (entity: EntityClass): ColumnContract[] => getMetadataArgsStorage()
|
||||
.columns.filter((column) => column.target === entity)
|
||||
.map((column) => ({
|
||||
default: column.options.default ?? null,
|
||||
length: column.options.length === undefined ? null : Number(column.options.length),
|
||||
name: `${column.options.name || column.propertyName}`,
|
||||
nullable: column.options.nullable === true,
|
||||
precision: column.options.precision === undefined ? null : Number(column.options.precision),
|
||||
primary: column.options.primary === true,
|
||||
propertyName: `${column.propertyName}`,
|
||||
type: column.options.type === undefined && ['createDate', 'updateDate'].includes(column.mode)
|
||||
? 'datetime'
|
||||
: normalizeColumnType(column.options.type),
|
||||
unsigned: column.options.unsigned === true,
|
||||
}));
|
||||
|
||||
/** Reads the database column names declared by a TypeORM entity. */
|
||||
const columnNamesOf = (entity: object) =>
|
||||
getMetadataArgsStorage()
|
||||
.columns.filter((column) => column.target === entity)
|
||||
.map((column) => `${column.options.name || column.propertyName}`);
|
||||
/** Reads an entity's TypeORM index definitions into exact name/column/unique tuples. */
|
||||
const getIndexes = (entity: EntityClass) => getMetadataArgsStorage().indices
|
||||
.filter((index) => index.target === entity)
|
||||
.map((index) => ({
|
||||
columns: Array.isArray(index.columns) ? index.columns.map((column) => `${column}`) : [],
|
||||
name: index.name,
|
||||
unique: index.unique === true,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
describe('QQBot message-push persistence contract', () => {
|
||||
it('registers the six message-push entities in QQBot Core', () => {
|
||||
expect(QQBOT_CORE_ENTITIES).toEqual(
|
||||
expect.arrayContaining(messagePushEntities),
|
||||
);
|
||||
it('registers exactly the six message-push entities in QQBot Core', () => {
|
||||
expect(QQBOT_CORE_ENTITIES).toEqual(expect.arrayContaining(persistenceContract.map(({ entity }) => entity)));
|
||||
});
|
||||
|
||||
it('maps every required message-push column with string Snowflake IDs', () => {
|
||||
for (const entity of messagePushEntities) {
|
||||
const tableName = tableNameOf(entity);
|
||||
it('maps every table to its independent exact column metadata matrix', () => {
|
||||
for (const { columns, entity, table } of persistenceContract) {
|
||||
const metadataTable = getMetadataArgsStorage().tables.find((entry) => entry.target === entity);
|
||||
expect(metadataTable?.name).toBe(table);
|
||||
expect(getColumns(entity)).toEqual(columns);
|
||||
}
|
||||
});
|
||||
|
||||
expect(tableName).toBeTruthy();
|
||||
expect(columnNamesOf(entity)).toEqual(
|
||||
expect.arrayContaining([...expectedColumns[tableName as keyof typeof expectedColumns]]),
|
||||
);
|
||||
it('marks create and update timestamps as generated TypeORM timestamp columns', () => {
|
||||
for (const { entity } of persistenceContract) {
|
||||
const timestamps = getMetadataArgsStorage().columns
|
||||
.filter((column) => column.target === entity)
|
||||
.filter((column) => ['createTime', 'updateTime'].includes(`${column.propertyName}`))
|
||||
.map((column) => ({
|
||||
mode: column.mode,
|
||||
precision: column.options.precision,
|
||||
type: column.options.type === undefined ? 'datetime' : normalizeColumnType(column.options.type),
|
||||
}));
|
||||
expect(timestamps).toEqual([
|
||||
{ mode: 'createDate', precision: 6, type: 'datetime' },
|
||||
{ mode: 'updateDate', precision: 6, type: 'datetime' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps all unique, dispatch, lease, and history indexes as exact tuples', () => {
|
||||
for (const { entity, indexes } of persistenceContract) {
|
||||
expect(getIndexes(entity)).toEqual([...indexes].sort((left, right) => left.name.localeCompare(right.name)));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every identifier property as a string and declares no ORM relations or foreign keys', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
for (const { columns, entity } of persistenceContract) {
|
||||
for (const { propertyName } of columns.filter(({ propertyName }) => propertyName === 'id' || propertyName.endsWith('Id'))) {
|
||||
expect(Reflect.getMetadata('design:type', entity.prototype, propertyName)).toBe(String);
|
||||
}
|
||||
expect(metadata.relations.filter((relation) => relation.target === entity)).toEqual([]);
|
||||
expect((metadata.foreignKeys || []).filter((foreignKey) => foreignKey.target === entity)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,42 +1,208 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const tables = [
|
||||
'qqbot_message_subscription',
|
||||
'qqbot_message_template',
|
||||
'qqbot_message_publish_binding',
|
||||
'qqbot_message_publish_target',
|
||||
'qqbot_message_event',
|
||||
'qqbot_message_delivery',
|
||||
] as const;
|
||||
type MenuEntry = readonly [
|
||||
id: string,
|
||||
pid: string,
|
||||
name: string,
|
||||
authCode: string,
|
||||
sort: string,
|
||||
];
|
||||
|
||||
const menuEntries = [
|
||||
['2041700000000100413', 'QqBotMessageSubscription', 'QqBot:MessageSubscription:List'],
|
||||
['2041700000000100414', 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List'],
|
||||
['2041700000000120461', 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List'],
|
||||
['2041700000000120462', 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create'],
|
||||
['2041700000000120463', 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update'],
|
||||
['2041700000000120464', 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete'],
|
||||
['2041700000000120465', 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle'],
|
||||
['2041700000000120471', 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List'],
|
||||
['2041700000000120472', 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create'],
|
||||
['2041700000000120473', 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update'],
|
||||
['2041700000000120474', 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete'],
|
||||
['2041700000000120475', 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle'],
|
||||
['2041700000000120476', 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview'],
|
||||
['2041700000000120481', 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List'],
|
||||
['2041700000000120482', 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create'],
|
||||
['2041700000000120483', 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update'],
|
||||
['2041700000000120484', 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete'],
|
||||
['2041700000000120485', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle'],
|
||||
] as const;
|
||||
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;
|
||||
|
||||
/** Reads SQL with case and quote differences removed for contract assertions. */
|
||||
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) =>
|
||||
readFileSync(resolve(process.cwd(), relativePath), 'utf8')
|
||||
.toLowerCase()
|
||||
.replace(/`/g, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
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');
|
||||
@ -45,53 +211,75 @@ describe('QQBot message-push SQL contract', () => {
|
||||
const verifySql = readNormalizedSql('sql/refactor-v3/99-verify.sql');
|
||||
const vbenSql = readNormalizedSql('sql/vben-admin-init.sql');
|
||||
|
||||
it.each(tables)('declares %s in current and bootstrap schema SQL', (table) => {
|
||||
expect(bootstrapSql).toContain(`create table if not exists ${table}`);
|
||||
expect(schemaSql).toContain(`create table if not exists ${table}`);
|
||||
});
|
||||
|
||||
it('keeps six-table indexes, datetime precision, and JSON fields aligned', () => {
|
||||
for (const sql of [bootstrapSql, schemaSql]) {
|
||||
expect(sql).toContain('uk_qqbot_message_subscription_active_key');
|
||||
expect(sql).toContain('uk_qqbot_message_publish_binding_active_key');
|
||||
expect(sql).toContain('uk_qqbot_message_publish_target_active_key');
|
||||
expect(sql).toContain('uk_qqbot_message_event_event_id');
|
||||
expect(sql).toContain('uk_qqbot_message_delivery_event_target');
|
||||
expect(sql).toContain('idx_qqbot_message_event_dispatch');
|
||||
expect(sql).toContain('idx_qqbot_message_event_lease');
|
||||
expect(sql).toContain('idx_qqbot_message_delivery_dispatch');
|
||||
expect(sql).toContain('idx_qqbot_message_delivery_lease');
|
||||
expect(sql).toContain('idx_qqbot_message_delivery_history');
|
||||
expect(sql).toContain('source_config json');
|
||||
expect(sql).toContain('payload json');
|
||||
expect(sql).toContain('variable_snapshot json');
|
||||
expect(sql).toContain('datetime(6)');
|
||||
}
|
||||
});
|
||||
|
||||
it('seeds the default template and every stable menu node idempotently', () => {
|
||||
for (const sql of [bootstrapSql, seedSql]) {
|
||||
expect(sql).toContain('2041700000000200601');
|
||||
expect(sql).toContain('network.stun.mapping-port-changed');
|
||||
expect(sql).toContain('当前stun的端口已变更为${{endpoint}}');
|
||||
expect(sql).toContain('where not exists');
|
||||
}
|
||||
|
||||
for (const [id, name, authCode] of menuEntries) {
|
||||
for (const sql of [bootstrapSql, seedSql, vbenSql]) {
|
||||
expect(sql).toContain(id);
|
||||
expect(sql).toContain(name.toLowerCase());
|
||||
expect(sql).toContain(authCode.toLowerCase());
|
||||
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('verifies every persisted message-push table and seeded menu contract', () => {
|
||||
for (const table of tables) {
|
||||
expect(verifySql).toContain(table);
|
||||
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())));
|
||||
}
|
||||
expect(verifySql).toContain('2041700000000200601');
|
||||
expect(verifySql).toContain('qqbot:messagesubscription:list');
|
||||
expect(verifySql).toContain('qqbot:account:messagepush:toggle');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user