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

1020 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.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: '端å<C2AF>£æ<C2A3><C3A6>é†',
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: ¸•é²<C3A9>æ°ä¸ç•Œ · pal.example.com',
valid: true,
},
): jest.Mocked<SystemMessageSourceAdapter> {
return {
definition: {
description: 'STUN 映射端å<C2AF>£å<C2A3>˜åŒ',
displayName: 'STUN 端å<C2AF>£å<C2A3>˜åŒ',
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: '忽略:æœ<C3A6>务必须使用实时 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(),
bindings: Array<{ isDeleted: boolean; subscriptionId: string }> = [],
additionalSources: SystemMessageSourceAdapter[] = [],
) {
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 bindingRepository = {
count: jest.fn(
async ({ where: { isDeleted, subscriptionId } }) =>
bindings.filter(
(binding) =>
binding.isDeleted === isDeleted &&
binding.subscriptionId === subscriptionId,
).length,
),
} as unknown as jest.Mocked<Repository<QqbotMessagePublishBinding>>;
const manager = {
getRepository: jest.fn((entity) => {
if (entity === QqbotMessageSubscription) return subscriptionRepository;
if (entity === QqbotMessagePublishBinding) return bindingRepository;
throw new Error('unexpected repository');
}),
} as unknown as jest.Mocked<EntityManager>;
Object.assign(subscriptionRepository, {
manager: { transaction: jest.fn((callback) => callback(manager)) },
});
const sourceRegistry = registry(source);
additionalSources.forEach((item) => sourceRegistry.register(item));
const service = new QqbotMessageSubscriptionService(
subscriptionRepository,
sourceRegistry,
);
return {
bindingRepository,
items,
manager,
service,
source,
sourceRegistry,
subscriptionRepository,
};
}
/**
* Builds two controlled create transactions that reject forbidden range locks with MySQL 1213.
* @param historical - Optional deleted candidate both transactions must contend to revive.
* @returns Shared rows, the service, and repository call evidence for concurrent-create assertions.
*/
function setupConcurrentCreateRace(historical?: QqbotMessageSubscription) {
const items = historical ? [historical] : [];
const bothActiveChecks = deferred();
const bothHistoricalLookups = deferred();
const contenderRequested = deferred();
const historicalRowReleased = deferred();
let activeChecks = 0;
let historicalLookups = 0;
let historicalLockAttempts = 0;
let createSequence = 1000;
const subscriptionRepository = {
create: jest.fn((input) =>
subscription({ id: String(createSequence++), ...input }),
),
findOne: jest.fn(async (options) => {
const where = options.where as FindOptionsWhere<QqbotMessageSubscription>;
if (where.activeKey !== undefined) {
if (options.lock?.mode === 'pessimistic_write') {
throw { code: 'ER_LOCK_DEADLOCK', errno: 1213 };
}
activeChecks += 1;
if (activeChecks === 2) bothActiveChecks.resolve();
await bothActiveChecks.promise;
return null;
}
if (
where.sourceConfigDigest !== undefined &&
where.sourceKey !== undefined
) {
if (options.lock?.mode === 'pessimistic_write') {
throw { code: 'ER_LOCK_DEADLOCK', errno: 1213 };
}
historicalLookups += 1;
if (historicalLookups === 2) bothHistoricalLookups.resolve();
await bothHistoricalLookups.promise;
return (
items
.filter(
(item) =>
item.isDeleted &&
item.sourceConfigDigest === where.sourceConfigDigest &&
item.sourceKey === where.sourceKey,
)
.sort((left, right) => {
const time = String(right.updateTime).localeCompare(
String(left.updateTime),
);
return time || right.id.localeCompare(left.id);
})[0] ?? null
);
}
if (where.id !== undefined && where.isDeleted === true) {
if (options.lock?.mode !== 'pessimistic_write') {
throw new Error('historical candidate must be locked by primary key');
}
historicalLockAttempts += 1;
if (historicalLockAttempts === 1) {
await contenderRequested.promise;
} else {
contenderRequested.resolve();
await historicalRowReleased.promise;
}
return (
items.find(
(item) => item.id === where.id && item.isDeleted === true,
) ?? null
);
}
throw new Error('unexpected subscription lookup');
}),
save: jest.fn(async (item: QqbotMessageSubscription) => {
const duplicate = items.find(
(candidate) =>
candidate.id !== item.id &&
!candidate.isDeleted &&
candidate.activeKey === item.activeKey,
);
if (duplicate) throw { code: 'ER_DUP_ENTRY', errno: 1062 };
const existing = items.find((candidate) => candidate.id === item.id);
if (existing) {
Object.assign(existing, item);
historicalRowReleased.resolve();
} 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)) },
});
return {
items,
service: new QqbotMessageSubscriptionService(
subscriptionRepository,
registry(adapter()),
),
subscriptionRepository,
};
}
/** Runs two same-key creates and returns their success or failure values without short-circuiting. */
async function runConcurrentCreates(
service: QqbotMessageSubscriptionService,
): Promise<unknown[]> {
const input = {
enabled: true,
name: 'å¹¶å<C2B6>订阅',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
};
return Promise.all([
service.create(input).catch((error: unknown) => error),
service.create(input).catch((error: unknown) => error),
]);
}
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: ¸•é²<C3A9>端å<C2AF>£å<C2A3>˜æ´',
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: 'é‡<C3A9>å¤<C3A5>',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).rejects.toMatchObject({ status: HttpStatus.CONFLICT });
await expect(
service.create({
enabled: true,
name: 'é‡<C3A9>å¤<C3A5>',
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: '竞æ€<C3A6>',
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('lets the unique key settle concurrent creates for a previously unseen natural key', async () => {
const { items, service, subscriptionRepository } =
setupConcurrentCreateRace();
const results = await runConcurrentCreates(service);
const failures = results.filter((result) => result instanceof Error);
const successes = results.filter((result) => !(result instanceof Error));
expect(successes).toHaveLength(1);
expect(failures).toHaveLength(1);
expect(failures[0]).toBeInstanceOf(HttpException);
expect(failures[0]).toMatchObject({ status: HttpStatus.CONFLICT });
expect(failures[0]).not.toMatchObject({ errno: 1213 });
expect(items.filter((item) => !item.isDeleted)).toHaveLength(1);
for (const [options] of subscriptionRepository.findOne.mock.calls) {
const where = options.where as FindOptionsWhere<QqbotMessageSubscription>;
if (
where.activeKey !== undefined ||
where.sourceConfigDigest !== undefined
) {
expect(options.lock).toBeUndefined();
}
}
});
it('locks only the selected history row before one concurrent revival wins', async () => {
const historical = subscription({
activeKey: null,
id: '102',
isDeleted: true,
});
const { items, service, subscriptionRepository } =
setupConcurrentCreateRace(historical);
const results = await runConcurrentCreates(service);
const failures = results.filter((result) => result instanceof Error);
const successes = results.filter((result) => !(result instanceof Error));
expect(successes).toHaveLength(1);
expect(failures).toHaveLength(1);
expect(failures[0]).toBeInstanceOf(HttpException);
expect(failures[0]).toMatchObject({ status: HttpStatus.CONFLICT });
expect(failures[0]).not.toMatchObject({ errno: 1213 });
expect(items).toEqual([
expect.objectContaining({ id: '102', isDeleted: false }),
]);
const candidateLocks = subscriptionRepository.findOne.mock.calls.filter(
([options]) => options.lock?.mode === 'pessimistic_write',
);
expect(candidateLocks).toHaveLength(2);
for (const [options] of candidateLocks) {
expect(options.where).toEqual({ id: '102', isDeleted: true });
}
});
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: 'å¤<C3A5>æ´»',
remark: '说明',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).resolves.toEqual(
expect.objectContaining({ id: '102', name: 'å¤<C3A5>æ´»', 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: '冲çª<C3A7>',
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('maps duplicate-key update races to Vben HTTP 409 and propagates other save failures', async () => {
const duplicateKeyRace = setup([subscription()]);
duplicateKeyRace.subscriptionRepository.save.mockRejectedValueOnce({
code: 'ER_DUP_ENTRY',
errno: 1062,
});
await expect(
duplicateKeyRace.service.update('100', {
enabled: true,
name: ´æ°ç«žæ€<C3A6>',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).rejects.toMatchObject({ status: HttpStatus.CONFLICT });
const infrastructureFailure = new Error('database unavailable');
const persistenceFailure = setup([subscription()]);
persistenceFailure.subscriptionRepository.save.mockRejectedValueOnce(
infrastructureFailure,
);
await expect(
persistenceFailure.service.update('100', {
enabled: true,
name: '更新故障',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).rejects.toBe(infrastructureFailure);
});
it('rejects opposite active-key updates as conflicts without taking a second write lock', async () => {
const secondConfig = {
ddnsRecordId: '2041700000000000004',
portForwardId: '2041700000000000003',
};
const first = subscription({ id: '100' });
const second = subscription({
activeKey: `${SOURCE_KEY}:${digestFor(secondConfig)}`,
id: '101',
sourceConfig: secondConfig,
sourceConfigDigest: digestFor(secondConfig),
});
const rows = [first, second];
const bothTargetsLocked = deferred();
let targetLocks = 0;
const repositories = ['100', '101'].map(
() =>
({
findOne: jest.fn(async (options) => {
const where =
options.where as FindOptionsWhere<QqbotMessageSubscription>;
if (where.id) {
if (options.lock?.mode === 'pessimistic_write') {
targetLocks += 1;
if (targetLocks === 2) bothTargetsLocked.resolve();
await bothTargetsLocked.promise;
}
return rows.find((row) => row.id === where.id) ?? null;
}
if (options.lock?.mode === 'pessimistic_write') {
throw { code: 'ER_LOCK_DEADLOCK', errno: 1213 };
}
return (
rows.find((row) => row.activeKey === where.activeKey) ?? null
);
}),
save: jest.fn(),
}) as unknown as jest.Mocked<Repository<QqbotMessageSubscription>>,
);
const managers = repositories.map(
(repository) =>
({
getRepository: jest.fn((entity) => {
if (entity === QqbotMessageSubscription) return repository;
throw new Error('unexpected repository');
}),
}) as unknown as EntityManager,
);
const source = adapter();
source.normalizeSubscriptionConfig.mockImplementation(async (input) => {
const config = input as typeof CONFIG;
const canonicalConfig =
config.portForwardId === secondConfig.portForwardId
? secondConfig
: CONFIG;
return {
canonicalConfig,
resourceKey: canonicalConfig.portForwardId,
sourceSummary: '交å<C2A4>‰æ´æ°',
};
});
const transaction = jest.fn((callback) => {
const manager = managers.shift();
if (!manager) throw new Error('unexpected transaction');
return callback(manager);
});
const service = new QqbotMessageSubscriptionService(
{
manager: { transaction },
} as unknown as Repository<QqbotMessageSubscription>,
registry(source),
);
const [firstError, secondError] = await Promise.all([
service
.update('100', {
enabled: true,
name: '改为第二æ<C592>¡é…<C3A9>ç½®',
sourceConfig: secondConfig,
sourceKey: SOURCE_KEY,
})
.catch((error: unknown) => error),
service
.update('101', {
enabled: true,
name: '改为第一æ<E282AC>¡é…<C3A9>ç½®',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
})
.catch((error: unknown) => error),
]);
expect(firstError).toMatchObject({ status: HttpStatus.CONFLICT });
expect(secondError).toMatchObject({ status: HttpStatus.CONFLICT });
for (const repository of repositories) {
expect(repository.findOne).toHaveBeenCalledWith(
expect.objectContaining({
lock: { mode: 'pessimistic_write' },
where: expect.objectContaining({ id: expect.any(String) }),
}),
);
expect(repository.findOne).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ activeKey: expect.any(String) }),
}),
);
const prospectiveRead = repository.findOne.mock.calls.find(
([options]) =>
(options.where as FindOptionsWhere<QqbotMessageSubscription>)
.activeKey !== undefined,
)?.[0];
expect(prospectiveRead?.lock).toBeUndefined();
}
});
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: ¸<C3A4>匹é…<C3A9>' }),
subscription({ id: '104', sourceKey: 'other.source' }),
],
source,
);
const page = await service.page({
enabled: true,
name: '端å<C2AF>£',
pageNo: 1,
pageSize: 10,
sourceKey: SOURCE_KEY,
});
expect(page).toEqual({
items: [
expect.objectContaining({
invalidReasonCode: 'ddns_mapping_mismatch',
sourceName: 'STUN 端å<C2AF>£å<C2A3>˜åŒ',
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: '%端å<C2AF>£%' }),
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: ˜ å°„关系å<C2BB>ˆæ³•,当å‰<C3A5>无租约',
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: ¸•é²<C3A9>æ°ä¸ç•Œ',
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: ¸•é²<C3A9>æ°ä¸ç•Œ · 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('rejects deletion and source changes while enabled or disabled live bindings reference the subscription', async () => {
const other = adapter();
Object.assign(other.definition, { sourceKey: 'other.source' });
const { bindingRepository, service, subscriptionRepository } = setup(
[subscription()],
adapter(),
[{ isDeleted: false, subscriptionId: '100' }],
[other],
);
await expect(service.remove('100')).rejects.toMatchObject({
code: 'invalid_source_config',
});
await expect(
service.update('100', {
enabled: false,
name: '改æº<C3A6>',
sourceConfig: CONFIG,
sourceKey: 'other.source',
}),
).rejects.toMatchObject({ code: 'invalid_source_config' });
await expect(
service.update('100', {
enabled: false,
name: <>Œæº<C3A6>ç¼è¾',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).resolves.toEqual(expect.objectContaining({ name: <>Œæº<C3A6>ç¼è¾' }));
expect(bindingRepository.count).toHaveBeenCalledWith({
where: { isDeleted: false, subscriptionId: '100' },
});
expect(subscriptionRepository.save).not.toHaveBeenCalledWith(
expect.objectContaining({ isDeleted: true }),
);
});
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;
if (entity === QqbotMessagePublishBinding) {
return { count: jest.fn(async () => 0) };
}
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,
}),
);
});
});