feat: 实现系统消息订阅管理
This commit is contained in:
parent
1a19730c18
commit
986615ac32
@ -0,0 +1,377 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { throwVbenError } from '@/common';
|
||||||
|
import {
|
||||||
|
Like,
|
||||||
|
Repository,
|
||||||
|
type EntityManager,
|
||||||
|
type FindOptionsWhere,
|
||||||
|
} from 'typeorm';
|
||||||
|
import {
|
||||||
|
SystemMessageContractError,
|
||||||
|
type MessageSubscriptionInput,
|
||||||
|
type MessageSubscriptionListQuery,
|
||||||
|
type MessageSubscriptionView,
|
||||||
|
} from '../../contract/message-push/qqbot-message-push.types';
|
||||||
|
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||||
|
import { SystemMessageSourceRegistry } from './system-message-source.registry';
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_NO = 1;
|
||||||
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
type NormalizedSubscriptionInput = {
|
||||||
|
activeKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
remark: null | string;
|
||||||
|
sourceConfig: Record<string, string>;
|
||||||
|
sourceConfigDigest: string;
|
||||||
|
sourceKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages source-normalized global system-message subscriptions and their binding lock gate.
|
||||||
|
*
|
||||||
|
* Database errors other than MySQL duplicate-key conflicts deliberately propagate so callers
|
||||||
|
* cannot treat incomplete persistence as a valid subscription lifecycle transition.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class QqbotMessageSubscriptionService {
|
||||||
|
/**
|
||||||
|
* Initializes subscription persistence and the live process-local source registry.
|
||||||
|
* @param subscriptionRepository - Persistence root used to open lifecycle transactions.
|
||||||
|
* @param sourceRegistry - Current registered source definitions and adapters.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(QqbotMessageSubscription)
|
||||||
|
private readonly subscriptionRepository: Repository<QqbotMessageSubscription>,
|
||||||
|
private readonly sourceRegistry: SystemMessageSourceRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages non-deleted subscriptions with current source validity rather than stale snapshots.
|
||||||
|
* @param query - Optional name, source key, enabled state, and pagination filters.
|
||||||
|
* @returns Deterministically ordered detached subscription views and their total count.
|
||||||
|
*/
|
||||||
|
async page(query: MessageSubscriptionListQuery): Promise<{
|
||||||
|
items: MessageSubscriptionView[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const pageNo = Math.max(
|
||||||
|
DEFAULT_PAGE_NO,
|
||||||
|
Math.floor(query.pageNo ?? DEFAULT_PAGE_NO),
|
||||||
|
);
|
||||||
|
const pageSize = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(query.pageSize ?? DEFAULT_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
const where: FindOptionsWhere<QqbotMessageSubscription> = {
|
||||||
|
isDeleted: false,
|
||||||
|
};
|
||||||
|
if (query.name) where.name = Like(`%${query.name}%`);
|
||||||
|
if (query.sourceKey) where.sourceKey = query.sourceKey;
|
||||||
|
if (query.enabled !== undefined) where.enabled = query.enabled;
|
||||||
|
|
||||||
|
const [subscriptions, total] =
|
||||||
|
await this.subscriptionRepository.findAndCount({
|
||||||
|
order: { createTime: 'DESC', id: 'DESC' },
|
||||||
|
skip: (pageNo - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
where,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: await Promise.all(subscriptions.map((item) => this.toView(item))),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a source-normalized subscription or revives its most recently updated history row.
|
||||||
|
* @param input - Untrusted metadata and source configuration submitted by management UI.
|
||||||
|
* @returns The new or revived subscription view.
|
||||||
|
* @throws {HttpException} HTTP 409 when another active row owns the same natural key.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
input: MessageSubscriptionInput,
|
||||||
|
): Promise<MessageSubscriptionView> {
|
||||||
|
const normalized = await this.normalizeInput(input);
|
||||||
|
try {
|
||||||
|
const saved = await this.subscriptionRepository.manager.transaction(
|
||||||
|
/** Serializes active-key ownership and deleted-row revival in one transaction. */
|
||||||
|
async (manager) => {
|
||||||
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
|
const active = await repository.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { activeKey: normalized.activeKey, isDeleted: false },
|
||||||
|
});
|
||||||
|
if (active) this.throwNaturalKeyConflict();
|
||||||
|
|
||||||
|
const historical = await repository.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
order: { updateTime: 'DESC', id: 'DESC' },
|
||||||
|
where: {
|
||||||
|
isDeleted: true,
|
||||||
|
sourceConfigDigest: normalized.sourceConfigDigest,
|
||||||
|
sourceKey: normalized.sourceKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (historical) {
|
||||||
|
Object.assign(historical, normalized, { isDeleted: false });
|
||||||
|
return repository.save(historical);
|
||||||
|
}
|
||||||
|
return repository.save(
|
||||||
|
repository.create({ ...normalized, isDeleted: false }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.toView(saved);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces one active subscription's metadata and canonical source configuration.
|
||||||
|
* @param id - String Snowflake identity of the subscription being changed.
|
||||||
|
* @param input - Complete replacement metadata and untrusted source configuration.
|
||||||
|
* @returns The updated view for the same subscription ID.
|
||||||
|
* @throws {HttpException} HTTP 409 when another active subscription owns the new natural key.
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: MessageSubscriptionInput,
|
||||||
|
): Promise<MessageSubscriptionView> {
|
||||||
|
const normalized = await this.normalizeInput(input);
|
||||||
|
const saved = await this.subscriptionRepository.manager.transaction(
|
||||||
|
/** Holds both target and prospective active-key rows through the update save. */
|
||||||
|
async (manager) => {
|
||||||
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
|
const current = await this.findActiveForWrite(repository, id);
|
||||||
|
const conflict = await repository.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { activeKey: normalized.activeKey, isDeleted: false },
|
||||||
|
});
|
||||||
|
if (conflict && conflict.id !== current.id) {
|
||||||
|
this.throwNaturalKeyConflict();
|
||||||
|
}
|
||||||
|
Object.assign(current, normalized);
|
||||||
|
return repository.save(current);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes an active subscription's enabled state after a live source validation when enabling.
|
||||||
|
* @param id - String Snowflake identity of the active subscription.
|
||||||
|
* @param enabled - Requested future enabled state.
|
||||||
|
* @returns The updated detached subscription view.
|
||||||
|
* @throws {SystemMessageContractError} With the current source reason when enabling is unsafe.
|
||||||
|
*/
|
||||||
|
async setEnabled(
|
||||||
|
id: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<MessageSubscriptionView> {
|
||||||
|
const saved = await this.subscriptionRepository.manager.transaction(
|
||||||
|
/** Holds the subscription row while current source validity is checked and persisted. */
|
||||||
|
async (manager) => {
|
||||||
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
|
const current = await this.findActiveForWrite(repository, id);
|
||||||
|
if (enabled) {
|
||||||
|
const inspection = await this.sourceRegistry
|
||||||
|
.get(current.sourceKey)
|
||||||
|
.inspectSubscription(current.sourceConfig);
|
||||||
|
if (!inspection.valid) {
|
||||||
|
throw new SystemMessageContractError(
|
||||||
|
inspection.invalidReasonCode || 'invalid_source_config',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.enabled = enabled;
|
||||||
|
return repository.save(current);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-deletes one active subscription and releases its active natural key atomically.
|
||||||
|
* @param id - String Snowflake identity of the subscription to remove.
|
||||||
|
* @returns `true` once disabled/deleted state and null active key were persisted.
|
||||||
|
*/
|
||||||
|
async remove(id: string): Promise<boolean> {
|
||||||
|
return this.subscriptionRepository.manager.transaction(
|
||||||
|
/** Holds the active row until its deletion-safe fields are stored together. */
|
||||||
|
async (manager) => {
|
||||||
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
|
const current = await this.findActiveForWrite(repository, id);
|
||||||
|
current.activeKey = null;
|
||||||
|
current.enabled = false;
|
||||||
|
current.isDeleted = true;
|
||||||
|
await repository.save(current);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the subscription half of the binding lock order before any live binding save.
|
||||||
|
*
|
||||||
|
* Callers must pass their current binding-write transaction manager, invoke this before
|
||||||
|
* saving every new, updated, or revived non-deleted binding, and keep that transaction open
|
||||||
|
* through the binding save and commit. Disabled bindings require this same lock because they
|
||||||
|
* still reference the subscription and must not race a concurrent removal.
|
||||||
|
* @param manager - Current binding transaction manager; never substitute a global manager.
|
||||||
|
* @param subscriptionId - Candidate subscription's string Snowflake identity.
|
||||||
|
* @param bindingEnabled - Whether the binding will be enabled for message delivery.
|
||||||
|
* @returns The locked active subscription available to the requested binding state.
|
||||||
|
* @throws {SystemMessageContractError} For deleted/missing subscriptions, disabled enabled-bindings, or invalid sources.
|
||||||
|
*/
|
||||||
|
async requireAvailableForBinding(
|
||||||
|
manager: EntityManager,
|
||||||
|
subscriptionId: string,
|
||||||
|
bindingEnabled: boolean,
|
||||||
|
): Promise<QqbotMessageSubscription> {
|
||||||
|
const subscription = await manager
|
||||||
|
.getRepository(QqbotMessageSubscription)
|
||||||
|
.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: subscriptionId, isDeleted: false },
|
||||||
|
});
|
||||||
|
if (!subscription) {
|
||||||
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
|
}
|
||||||
|
if (!bindingEnabled) return subscription;
|
||||||
|
if (!subscription.enabled) {
|
||||||
|
throw new SystemMessageContractError('subscription_disabled');
|
||||||
|
}
|
||||||
|
const inspection = await this.sourceRegistry
|
||||||
|
.get(subscription.sourceKey)
|
||||||
|
.inspectSubscription(subscription.sourceConfig);
|
||||||
|
if (!inspection.valid) {
|
||||||
|
throw new SystemMessageContractError(
|
||||||
|
inspection.invalidReasonCode || 'invalid_source_config',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes user input through the exact source adapter before any transaction begins.
|
||||||
|
* @param input - Untrusted management payload with a requested source key.
|
||||||
|
* @returns Detached canonical persistence fields and their stable SHA-256 natural key.
|
||||||
|
*/
|
||||||
|
private async normalizeInput(
|
||||||
|
input: MessageSubscriptionInput,
|
||||||
|
): Promise<NormalizedSubscriptionInput> {
|
||||||
|
const adapter = this.sourceRegistry.get(input.sourceKey);
|
||||||
|
const normalized = await adapter.normalizeSubscriptionConfig(
|
||||||
|
input.sourceConfig,
|
||||||
|
);
|
||||||
|
const sourceConfig = this.sortConfig(normalized.canonicalConfig);
|
||||||
|
const sourceConfigDigest = createHash('sha256')
|
||||||
|
.update(JSON.stringify(sourceConfig))
|
||||||
|
.digest('hex');
|
||||||
|
return {
|
||||||
|
activeKey: `${input.sourceKey}:${sourceConfigDigest}`,
|
||||||
|
enabled: input.enabled,
|
||||||
|
name: input.name.trim(),
|
||||||
|
remark: input.remark?.trim() || null,
|
||||||
|
sourceConfig,
|
||||||
|
sourceConfigDigest,
|
||||||
|
sourceKey: input.sourceKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts a canonical configuration into a detached ordinary object for stable JSON hashing.
|
||||||
|
* @param config - Adapter-owned allowlisted scalar configuration.
|
||||||
|
* @returns New key-sorted object that cannot mutate the adapter-owned input object.
|
||||||
|
*/
|
||||||
|
private sortConfig(config: Record<string, string>): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(config).sort(([left], [right]) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks and loads one active subscription for a direct lifecycle mutation.
|
||||||
|
* @param repository - Repository obtained from the caller's current transaction manager.
|
||||||
|
* @param id - String Snowflake identity of the expected active subscription.
|
||||||
|
* @returns Locked active subscription row.
|
||||||
|
* @throws {SystemMessageContractError} When the row is absent or already soft-deleted.
|
||||||
|
*/
|
||||||
|
private async findActiveForWrite(
|
||||||
|
repository: Repository<QqbotMessageSubscription>,
|
||||||
|
id: string,
|
||||||
|
): Promise<QqbotMessageSubscription> {
|
||||||
|
const current = await repository.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id, isDeleted: false },
|
||||||
|
});
|
||||||
|
if (!current) throw new SystemMessageContractError('invalid_source_config');
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps duplicate natural keys to the existing Vben-compatible HTTP conflict response.
|
||||||
|
* @returns Never returns because it always throws an HTTP 409 exception.
|
||||||
|
*/
|
||||||
|
private throwNaturalKeyConflict(): never {
|
||||||
|
return throwVbenError('相同消息源配置的订阅已存在', HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognizes only MySQL duplicate-key failures that are the database's concurrency authority.
|
||||||
|
* @param error - Unknown exception returned by transaction, repository, or driver layers.
|
||||||
|
* @returns Whether the error represents MySQL `ER_DUP_ENTRY` / errno 1062.
|
||||||
|
*/
|
||||||
|
private isDuplicateKeyError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
const value = error as { code?: unknown; errno?: unknown };
|
||||||
|
return value.code === 'ER_DUP_ENTRY' || value.errno === 1062;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a persistence row to a detached view using its current source definition and validity.
|
||||||
|
* @param subscription - Persisted subscription row whose source configuration must not leak mutability.
|
||||||
|
* @returns Serializable management view with live source summary and validity fields.
|
||||||
|
*/
|
||||||
|
private async toView(
|
||||||
|
subscription: QqbotMessageSubscription,
|
||||||
|
): Promise<MessageSubscriptionView> {
|
||||||
|
const adapter = this.sourceRegistry.get(subscription.sourceKey);
|
||||||
|
const inspection = await adapter.inspectSubscription(
|
||||||
|
subscription.sourceConfig,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
createTime: this.serializeTime(subscription.createTime),
|
||||||
|
enabled: subscription.enabled,
|
||||||
|
id: String(subscription.id),
|
||||||
|
invalidReasonCode: inspection.invalidReasonCode,
|
||||||
|
name: subscription.name,
|
||||||
|
remark: subscription.remark?.trim() || null,
|
||||||
|
sourceConfig: structuredClone(
|
||||||
|
subscription.sourceConfig,
|
||||||
|
) as unknown as MessageSubscriptionView['sourceConfig'],
|
||||||
|
sourceKey: subscription.sourceKey,
|
||||||
|
sourceName: adapter.definition.displayName,
|
||||||
|
sourceSummary: inspection.sourceSummary,
|
||||||
|
updateTime: this.serializeTime(subscription.updateTime),
|
||||||
|
valid: inspection.valid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes project datetime values without silently converting them to UTC ISO text.
|
||||||
|
* @param value - Entity date value transformed by the project's KtDateTime column type.
|
||||||
|
* @returns Project-formatted datetime text used by the management contract.
|
||||||
|
*/
|
||||||
|
private serializeTime(value: QqbotMessageSubscription['createTime']): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,7 @@ import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/pe
|
|||||||
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||||
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
|
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
|
||||||
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
|
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
|
||||||
|
import { QqbotMessageSubscriptionService } from './application/message-push/qqbot-message-subscription.service';
|
||||||
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service';
|
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service';
|
||||||
|
|
||||||
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||||
@ -87,6 +88,7 @@ export const QQBOT_CORE_CONTROLLERS = [
|
|||||||
export const QQBOT_CORE_PROVIDERS = [
|
export const QQBOT_CORE_PROVIDERS = [
|
||||||
SystemMessageSourceRegistry,
|
SystemMessageSourceRegistry,
|
||||||
SystemMessageTemplateRendererService,
|
SystemMessageTemplateRendererService,
|
||||||
|
QqbotMessageSubscriptionService,
|
||||||
QqbotMessageTemplateService,
|
QqbotMessageTemplateService,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotBusService,
|
QqbotBusService,
|
||||||
@ -110,6 +112,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
export const QQBOT_CORE_EXPORTS = [
|
export const QQBOT_CORE_EXPORTS = [
|
||||||
SystemMessageSourceRegistry,
|
SystemMessageSourceRegistry,
|
||||||
SystemMessageTemplateRendererService,
|
SystemMessageTemplateRendererService,
|
||||||
|
QqbotMessageSubscriptionService,
|
||||||
QqbotMessageTemplateService,
|
QqbotMessageTemplateService,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotConfigService,
|
QqbotConfigService,
|
||||||
|
|||||||
@ -0,0 +1,626 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import type { EntityManager, FindOptionsWhere, Repository } from 'typeorm';
|
||||||
|
import { KtDateTime } from '../../../../src/common';
|
||||||
|
import {
|
||||||
|
SystemMessageContractError,
|
||||||
|
type SystemMessageSourceAdapter,
|
||||||
|
} from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
|
||||||
|
import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
|
||||||
|
import { QqbotMessageSubscriptionService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service';
|
||||||
|
import { NetworkStunMessageSourceAdapter } from '../../../../src/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
|
||||||
|
import { NetworkDdnsRecord } from '../../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
|
||||||
|
import { NetworkPortForward } from '../../../../src/modules/admin/platform-config/network-management/network-management.entity';
|
||||||
|
import { QqbotMessageSubscription } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||||
|
|
||||||
|
const SOURCE_KEY = 'network.stun.mapping-port-changed';
|
||||||
|
const NOW = new KtDateTime('2026-07-24 08:09:10');
|
||||||
|
const CONFIG = {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creates a subscription fixture with deterministic string IDs and project timestamps. */
|
||||||
|
function subscription(
|
||||||
|
overrides: Partial<QqbotMessageSubscription> = {},
|
||||||
|
): QqbotMessageSubscription {
|
||||||
|
const sourceConfig = { ...CONFIG };
|
||||||
|
const digest = digestFor(sourceConfig);
|
||||||
|
return {
|
||||||
|
activeKey: `${SOURCE_KEY}:${digest}`,
|
||||||
|
createId: jest.fn(),
|
||||||
|
createTime: NOW,
|
||||||
|
enabled: true,
|
||||||
|
id: '100',
|
||||||
|
isDeleted: false,
|
||||||
|
name: '端口提醒',
|
||||||
|
remark: null,
|
||||||
|
sourceConfig,
|
||||||
|
sourceConfigDigest: digest,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
updateTime: NOW,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculates the exact stable digest that production natural-key persistence requires. */
|
||||||
|
function digestFor(config: Record<string, string>): string {
|
||||||
|
const json = JSON.stringify(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(config).sort(([left], [right]) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return createHash('sha256').update(json).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads TypeORM Like's internal test value for high-fidelity in-memory filtering. */
|
||||||
|
function likeValue(value: unknown): string | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const candidate = value as { _value?: unknown };
|
||||||
|
return typeof candidate._value === 'string' ? candidate._value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a promise gate whose release is controlled by a concurrency test. */
|
||||||
|
function deferred(): {
|
||||||
|
promise: Promise<void>;
|
||||||
|
resolve: () => void;
|
||||||
|
} {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((next) => {
|
||||||
|
resolve = next;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a controllable adapter whose current validity can change after persistence. */
|
||||||
|
function adapter(
|
||||||
|
inspect = {
|
||||||
|
invalidReasonCode: null,
|
||||||
|
sourceSummary: '帕鲁新世界 · pal.example.com',
|
||||||
|
valid: true,
|
||||||
|
},
|
||||||
|
): jest.Mocked<SystemMessageSourceAdapter> {
|
||||||
|
return {
|
||||||
|
definition: {
|
||||||
|
description: 'STUN 映射端口变化',
|
||||||
|
displayName: 'STUN 端口变化',
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
subscriptionFields: [],
|
||||||
|
variables: [],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
inspectSubscription: jest.fn(async (config: Record<string, unknown>) => {
|
||||||
|
void config;
|
||||||
|
return inspect;
|
||||||
|
}),
|
||||||
|
listSubscriptionOptions: jest.fn(),
|
||||||
|
normalizeSubscriptionConfig: jest.fn(async (input: unknown) => {
|
||||||
|
void input;
|
||||||
|
return {
|
||||||
|
canonicalConfig: { ...CONFIG },
|
||||||
|
resourceKey: CONFIG.portForwardId,
|
||||||
|
sourceSummary: '忽略:服务必须使用实时 inspect 结果',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
resolveDelivery: jest.fn(),
|
||||||
|
validateEventPayload: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers one test source adapter in a fresh process-local source registry. */
|
||||||
|
function registry(
|
||||||
|
source: SystemMessageSourceAdapter,
|
||||||
|
): SystemMessageSourceRegistry {
|
||||||
|
const value = new SystemMessageSourceRegistry();
|
||||||
|
value.register(source);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a transaction-aware repository fake backed by mutable subscription rows. */
|
||||||
|
function setup(
|
||||||
|
items: QqbotMessageSubscription[] = [],
|
||||||
|
source: SystemMessageSourceAdapter = adapter(),
|
||||||
|
) {
|
||||||
|
let createSequence = 1000;
|
||||||
|
const subscriptionRepository = {
|
||||||
|
create: jest.fn((input) =>
|
||||||
|
subscription({ id: String(createSequence++), ...input }),
|
||||||
|
),
|
||||||
|
findAndCount: jest.fn(
|
||||||
|
async ({
|
||||||
|
skip = 0,
|
||||||
|
take = items.length,
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
where: FindOptionsWhere<QqbotMessageSubscription>;
|
||||||
|
}) => {
|
||||||
|
const name = likeValue(where.name)?.slice(1, -1);
|
||||||
|
const matched = items.filter((item) => {
|
||||||
|
if (item.isDeleted !== where.isDeleted) return false;
|
||||||
|
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where.sourceKey && item.sourceKey !== where.sourceKey)
|
||||||
|
return false;
|
||||||
|
return !name || item.name.includes(name);
|
||||||
|
});
|
||||||
|
return [matched.slice(skip, skip + take), matched.length];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
findOne: jest.fn(async (options) => {
|
||||||
|
const where = options.where as FindOptionsWhere<QqbotMessageSubscription>;
|
||||||
|
const candidates = items.filter((item) => {
|
||||||
|
if (where.id !== undefined && item.id !== where.id) return false;
|
||||||
|
if (
|
||||||
|
where.activeKey !== undefined &&
|
||||||
|
item.activeKey !== where.activeKey
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
where.sourceKey !== undefined &&
|
||||||
|
item.sourceKey !== where.sourceKey
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
where.sourceConfigDigest !== undefined &&
|
||||||
|
item.sourceConfigDigest !== where.sourceConfigDigest
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
where.isDeleted === undefined || item.isDeleted === where.isDeleted
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (options.order?.updateTime === 'DESC') {
|
||||||
|
candidates.sort((left, right) => {
|
||||||
|
const time = String(right.updateTime).localeCompare(
|
||||||
|
String(left.updateTime),
|
||||||
|
);
|
||||||
|
return time || right.id.localeCompare(left.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}),
|
||||||
|
save: jest.fn(async (item: QqbotMessageSubscription) => {
|
||||||
|
const existing = items.find((candidate) => candidate.id === item.id);
|
||||||
|
if (existing) Object.assign(existing, item);
|
||||||
|
else items.push(item);
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
} as unknown as jest.Mocked<Repository<QqbotMessageSubscription>>;
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) => {
|
||||||
|
if (entity === QqbotMessageSubscription) return subscriptionRepository;
|
||||||
|
throw new Error('unexpected repository');
|
||||||
|
}),
|
||||||
|
} as unknown as jest.Mocked<EntityManager>;
|
||||||
|
Object.assign(subscriptionRepository, {
|
||||||
|
manager: { transaction: jest.fn((callback) => callback(manager)) },
|
||||||
|
});
|
||||||
|
const service = new QqbotMessageSubscriptionService(
|
||||||
|
subscriptionRepository,
|
||||||
|
registry(source),
|
||||||
|
);
|
||||||
|
return { items, manager, service, source, subscriptionRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QqbotMessageSubscriptionService', () => {
|
||||||
|
it('uses sorted allowlisted config JSON for the active natural key', async () => {
|
||||||
|
const source = adapter();
|
||||||
|
source.normalizeSubscriptionConfig.mockResolvedValue({
|
||||||
|
canonicalConfig: {
|
||||||
|
portForwardId: CONFIG.portForwardId,
|
||||||
|
ddnsRecordId: CONFIG.ddnsRecordId,
|
||||||
|
},
|
||||||
|
resourceKey: CONFIG.portForwardId,
|
||||||
|
sourceSummary: 'ignored',
|
||||||
|
});
|
||||||
|
const { service, subscriptionRepository } = setup([], source);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
enabled: true,
|
||||||
|
name: '帕鲁端口变更',
|
||||||
|
remark: '',
|
||||||
|
sourceConfig: { ...CONFIG, ignored: 'discarded' },
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ sourceConfig: CONFIG }));
|
||||||
|
|
||||||
|
const saved = subscriptionRepository.save.mock.calls[0][0];
|
||||||
|
const expectedDigest = digestFor(CONFIG);
|
||||||
|
expect(saved.sourceConfig).toEqual(CONFIG);
|
||||||
|
expect(saved.sourceConfigDigest).toBe(expectedDigest);
|
||||||
|
expect(saved.activeKey).toBe(`${SOURCE_KEY}:${expectedDigest}`);
|
||||||
|
expect(source.normalizeSubscriptionConfig).toHaveBeenCalledWith({
|
||||||
|
...CONFIG,
|
||||||
|
ignored: 'discarded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Vben HTTP 409 for an active duplicate and a duplicate-key create race', async () => {
|
||||||
|
const existing = subscription();
|
||||||
|
const { service } = setup([existing]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
enabled: true,
|
||||||
|
name: '重复',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ status: HttpStatus.CONFLICT });
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
enabled: true,
|
||||||
|
name: '重复',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).rejects.not.toBeInstanceOf(SystemMessageContractError);
|
||||||
|
|
||||||
|
const race = setup();
|
||||||
|
race.subscriptionRepository.save.mockRejectedValueOnce({
|
||||||
|
code: 'ER_DUP_ENTRY',
|
||||||
|
errno: 1062,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
race.service.create({
|
||||||
|
enabled: true,
|
||||||
|
name: '竞态',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(HttpException);
|
||||||
|
race.subscriptionRepository.save.mockRejectedValueOnce(
|
||||||
|
new Error('database unavailable'),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
race.service.create({
|
||||||
|
enabled: true,
|
||||||
|
name: '故障',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('database unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deletes safely and revives the newest matching historical row with its original ID', async () => {
|
||||||
|
const older = subscription({
|
||||||
|
activeKey: null,
|
||||||
|
id: '101',
|
||||||
|
isDeleted: true,
|
||||||
|
updateTime: new KtDateTime('2026-07-23 08:09:10'),
|
||||||
|
});
|
||||||
|
const newest = subscription({
|
||||||
|
activeKey: null,
|
||||||
|
enabled: false,
|
||||||
|
id: '102',
|
||||||
|
isDeleted: true,
|
||||||
|
updateTime: NOW,
|
||||||
|
});
|
||||||
|
const { items, service, subscriptionRepository } = setup([older, newest]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
enabled: true,
|
||||||
|
name: '复活',
|
||||||
|
remark: '说明',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: '102', name: '复活', remark: '说明' }),
|
||||||
|
);
|
||||||
|
expect(newest).toEqual(
|
||||||
|
expect.objectContaining({ enabled: true, isDeleted: false }),
|
||||||
|
);
|
||||||
|
expect(older.isDeleted).toBe(true);
|
||||||
|
|
||||||
|
await expect(service.remove('102')).resolves.toBe(true);
|
||||||
|
expect(items.find((item) => item.id === '102')).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
activeKey: null,
|
||||||
|
enabled: false,
|
||||||
|
isDeleted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(subscriptionRepository.findOne).toHaveBeenLastCalledWith({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: '102', isDeleted: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the same ID while rejecting another active natural key', async () => {
|
||||||
|
const first = subscription({ id: '100' });
|
||||||
|
const secondConfig = {
|
||||||
|
ddnsRecordId: '2041700000000000004',
|
||||||
|
portForwardId: '2041700000000000003',
|
||||||
|
};
|
||||||
|
const second = subscription({
|
||||||
|
activeKey: `${SOURCE_KEY}:${digestFor(secondConfig)}`,
|
||||||
|
id: '101',
|
||||||
|
sourceConfig: secondConfig,
|
||||||
|
sourceConfigDigest: digestFor(secondConfig),
|
||||||
|
});
|
||||||
|
const source = adapter();
|
||||||
|
const { service } = setup([first, second], source);
|
||||||
|
|
||||||
|
source.normalizeSubscriptionConfig.mockResolvedValueOnce({
|
||||||
|
canonicalConfig: secondConfig,
|
||||||
|
resourceKey: secondConfig.portForwardId,
|
||||||
|
sourceSummary: 'other',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.update('100', {
|
||||||
|
enabled: true,
|
||||||
|
name: '冲突',
|
||||||
|
sourceConfig: secondConfig,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ status: HttpStatus.CONFLICT });
|
||||||
|
|
||||||
|
source.normalizeSubscriptionConfig.mockResolvedValueOnce({
|
||||||
|
canonicalConfig: CONFIG,
|
||||||
|
resourceKey: CONFIG.portForwardId,
|
||||||
|
sourceSummary: 'current',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.update('100', {
|
||||||
|
enabled: false,
|
||||||
|
name: '更新',
|
||||||
|
remark: ' 新说明 ',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
enabled: false,
|
||||||
|
id: '100',
|
||||||
|
name: '更新',
|
||||||
|
remark: '新说明',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pages only undeleted records with real filters and returns detached, real-time source status', async () => {
|
||||||
|
const source = adapter({
|
||||||
|
invalidReasonCode: 'ddns_mapping_mismatch',
|
||||||
|
sourceSummary: '已失效',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
const first = subscription();
|
||||||
|
const { service, subscriptionRepository } = setup(
|
||||||
|
[
|
||||||
|
first,
|
||||||
|
subscription({ id: '101', isDeleted: true }),
|
||||||
|
subscription({ enabled: false, id: '102' }),
|
||||||
|
subscription({ id: '103', name: '不匹配' }),
|
||||||
|
subscription({ id: '104', sourceKey: 'other.source' }),
|
||||||
|
],
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = await service.page({
|
||||||
|
enabled: true,
|
||||||
|
name: '端口',
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
});
|
||||||
|
expect(page).toEqual({
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
invalidReasonCode: 'ddns_mapping_mismatch',
|
||||||
|
sourceName: 'STUN 端口变化',
|
||||||
|
sourceSummary: '已失效',
|
||||||
|
valid: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
page.items[0].sourceConfig.portForwardId = 'mutated';
|
||||||
|
expect(first.sourceConfig.portForwardId).toBe(CONFIG.portForwardId);
|
||||||
|
expect(subscriptionRepository.findAndCount).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
skip: 0,
|
||||||
|
take: 10,
|
||||||
|
where: {
|
||||||
|
enabled: true,
|
||||||
|
isDeleted: false,
|
||||||
|
name: expect.objectContaining({ _type: 'like', _value: '%端口%' }),
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a structurally valid source valid without a current Keeper lease and rejects only enabling an invalid relationship', async () => {
|
||||||
|
const leaseMissing = adapter({
|
||||||
|
invalidReasonCode: null,
|
||||||
|
sourceSummary: '映射关系合法,当前无租约',
|
||||||
|
valid: true,
|
||||||
|
});
|
||||||
|
const valid = setup([subscription({ enabled: false })], leaseMissing);
|
||||||
|
await expect(valid.service.page({})).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
items: [expect.objectContaining({ valid: true })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(valid.service.setEnabled('100', true)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ enabled: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = adapter({
|
||||||
|
invalidReasonCode: 'ddns_mapping_mismatch',
|
||||||
|
sourceSummary: '失效',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
const blocked = setup([subscription({ enabled: false })], invalid);
|
||||||
|
await expect(blocked.service.setEnabled('100', true)).rejects.toMatchObject(
|
||||||
|
{ code: 'ddns_mapping_mismatch' },
|
||||||
|
);
|
||||||
|
await expect(blocked.service.setEnabled('100', false)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ enabled: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the real STUN adapter to keep a structurally valid subscription valid without a lease', async () => {
|
||||||
|
const mapping = {
|
||||||
|
desiredPresence: 'present',
|
||||||
|
externalPort: 8213,
|
||||||
|
id: CONFIG.portForwardId,
|
||||||
|
internalPort: 8213,
|
||||||
|
isDeleted: false,
|
||||||
|
keeperDesiredEnabled: true,
|
||||||
|
name: '帕鲁新世界',
|
||||||
|
protocol: 'udp',
|
||||||
|
} as NetworkPortForward;
|
||||||
|
const record = {
|
||||||
|
domain: 'example.com',
|
||||||
|
enabled: true,
|
||||||
|
id: CONFIG.ddnsRecordId,
|
||||||
|
isDeleted: false,
|
||||||
|
portForwardId: CONFIG.portForwardId,
|
||||||
|
recordType: 'A',
|
||||||
|
sourceType: 'port_forward_ipv4',
|
||||||
|
subDomain: 'pal',
|
||||||
|
} as NetworkDdnsRecord;
|
||||||
|
const source = new NetworkStunMessageSourceAdapter(
|
||||||
|
{
|
||||||
|
findOne: jest.fn(async () => mapping),
|
||||||
|
} as unknown as Repository<NetworkPortForward>,
|
||||||
|
{
|
||||||
|
findOne: jest.fn(async () => record),
|
||||||
|
} as unknown as Repository<NetworkDdnsRecord>,
|
||||||
|
new SystemMessageSourceRegistry(),
|
||||||
|
);
|
||||||
|
const { service } = setup([subscription()], source);
|
||||||
|
|
||||||
|
await expect(service.page({})).resolves.toEqual({
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
invalidReasonCode: null,
|
||||||
|
sourceSummary: '帕鲁新世界 · pal.example.com',
|
||||||
|
valid: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the caller transaction lock for binding availability and permits disabled bindings only for non-deleted subscriptions', async () => {
|
||||||
|
const source = adapter({
|
||||||
|
invalidReasonCode: 'ddns_mapping_mismatch',
|
||||||
|
sourceSummary: '失效',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
const current = subscription();
|
||||||
|
const { manager, service, subscriptionRepository } = setup(
|
||||||
|
[current],
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.requireAvailableForBinding(manager, '100', false),
|
||||||
|
).resolves.toBe(current);
|
||||||
|
await expect(
|
||||||
|
service.requireAvailableForBinding(manager, '100', true),
|
||||||
|
).rejects.toMatchObject({ code: 'ddns_mapping_mismatch' });
|
||||||
|
current.enabled = false;
|
||||||
|
await expect(
|
||||||
|
service.requireAvailableForBinding(manager, '100', true),
|
||||||
|
).rejects.toMatchObject({ code: 'subscription_disabled' });
|
||||||
|
current.isDeleted = true;
|
||||||
|
await expect(
|
||||||
|
service.requireAvailableForBinding(manager, '100', false),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_source_config' });
|
||||||
|
expect(manager.getRepository).toHaveBeenCalledWith(
|
||||||
|
QqbotMessageSubscription,
|
||||||
|
);
|
||||||
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: '100', isDeleted: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a binding transaction observe deletion after the shared subscription row lock commits', async () => {
|
||||||
|
const current = subscription();
|
||||||
|
const deleteHasLock = deferred();
|
||||||
|
const allowDeleteCommit = deferred();
|
||||||
|
let lockTail = Promise.resolve();
|
||||||
|
|
||||||
|
/** Creates a manager whose pessimistic row lock lasts until its transaction callback settles. */
|
||||||
|
const transactionManager = (() => {
|
||||||
|
let releaseLock: (() => void) | undefined;
|
||||||
|
const acquireLock = async (): Promise<void> => {
|
||||||
|
const previous = lockTail;
|
||||||
|
const next = deferred();
|
||||||
|
lockTail = next.promise;
|
||||||
|
await previous;
|
||||||
|
releaseLock = next.resolve;
|
||||||
|
};
|
||||||
|
const repository = {
|
||||||
|
findOne: jest.fn(async (options) => {
|
||||||
|
if (options.lock?.mode === 'pessimistic_write') {
|
||||||
|
await acquireLock();
|
||||||
|
deleteHasLock.resolve();
|
||||||
|
}
|
||||||
|
return current.isDeleted ? null : current;
|
||||||
|
}),
|
||||||
|
save: jest.fn(async (item: QqbotMessageSubscription) => {
|
||||||
|
if (item.isDeleted) await allowDeleteCommit.promise;
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
} as unknown as Repository<QqbotMessageSubscription>;
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) => {
|
||||||
|
if (entity === QqbotMessageSubscription) return repository;
|
||||||
|
throw new Error('unexpected repository');
|
||||||
|
}),
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
Object.assign(manager, {
|
||||||
|
transaction: async <T>(
|
||||||
|
callback: (currentManager: EntityManager) => Promise<T>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await callback(manager);
|
||||||
|
} finally {
|
||||||
|
releaseLock?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return manager;
|
||||||
|
})();
|
||||||
|
const service = new QqbotMessageSubscriptionService(
|
||||||
|
{ manager: transactionManager } as Repository<QqbotMessageSubscription>,
|
||||||
|
registry(adapter()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletion = service.remove('100');
|
||||||
|
await deleteHasLock.promise;
|
||||||
|
const binding = transactionManager.transaction((manager) =>
|
||||||
|
service.requireAvailableForBinding(manager, '100', false),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
allowDeleteCommit.resolve();
|
||||||
|
|
||||||
|
await expect(deletion).resolves.toBe(true);
|
||||||
|
await expect(binding).rejects.toMatchObject({
|
||||||
|
code: 'invalid_source_config',
|
||||||
|
});
|
||||||
|
expect(current).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
activeKey: null,
|
||||||
|
enabled: false,
|
||||||
|
isDeleted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user