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

217 lines
12 KiB
TypeScript

import { getMetadataArgsStorage } from 'typeorm';
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';
import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
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';
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 },
{ name: 'idx_qqbot_message_event_source_resource_order', columns: ['sourceKey', 'resourceKey', 'occurredAt', 'id'], 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 },
],
},
];
/** 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();
};
/** Sorts metadata tuples by property name so declaration order cannot affect the contract. */
const sortByPropertyName = <T extends { propertyName: string }>(values: readonly T[]) =>
[...values].sort((left, right) => left.propertyName.localeCompare(right.propertyName));
/** 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,
}))
.sort((left, right) => left.propertyName.localeCompare(right.propertyName));
/** Selects registered QQBot Core entities whose TypeORM table is in the message-push namespace. */
const getRegisteredMessagePushEntities = () => QQBOT_CORE_ENTITIES
.filter((entity) => {
const table = getMetadataArgsStorage().tables.find((entry) => entry.target === entity);
return typeof table?.name === 'string' && table.name.startsWith('qqbot_message_');
})
.sort((left, right) => left.name.localeCompare(right.name));
/** 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 exactly the six message-push entities in QQBot Core', () => {
expect(getRegisteredMessagePushEntities()).toEqual(
[...persistenceContract.map(({ entity }) => entity)].sort((left, right) => left.name.localeCompare(right.name)),
);
});
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(sortByPropertyName(columns));
}
});
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,
propertyName: `${column.propertyName}`,
type: column.options.type === undefined ? 'datetime' : normalizeColumnType(column.options.type),
}));
expect(sortByPropertyName(timestamps)).toEqual([
{ mode: 'createDate', precision: 6, propertyName: 'createTime', type: 'datetime' },
{ mode: 'updateDate', precision: 6, propertyName: 'updateTime', 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([]);
}
});
});