fix: 保证消息发布绑定依赖一致性
This commit is contained in:
parent
b1e346bfd7
commit
bf14079bde
@ -103,7 +103,13 @@ export class QqbotAccountMessagePushService {
|
|||||||
if (historicalCandidate) {
|
if (historicalCandidate) {
|
||||||
const historical = await bindings.findOne({
|
const historical = await bindings.findOne({
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: 'pessimistic_write' },
|
||||||
where: { id: historicalCandidate.id, isDeleted: true },
|
where: {
|
||||||
|
accountId: String(account.id),
|
||||||
|
id: historicalCandidate.id,
|
||||||
|
isDeleted: true,
|
||||||
|
selfId: String(account.selfId),
|
||||||
|
subscriptionId: String(input.subscriptionId),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (historical) {
|
if (historical) {
|
||||||
Object.assign(
|
Object.assign(
|
||||||
@ -364,9 +370,11 @@ export class QqbotAccountMessagePushService {
|
|||||||
): Promise<QqbotMessagePublishBindingView> {
|
): Promise<QqbotMessagePublishBindingView> {
|
||||||
const [subscription, template, targets] = await Promise.all([
|
const [subscription, template, targets] = await Promise.all([
|
||||||
this.subscriptionRepository.findOne({
|
this.subscriptionRepository.findOne({
|
||||||
where: { id: binding.subscriptionId },
|
where: { id: binding.subscriptionId, isDeleted: false },
|
||||||
|
}),
|
||||||
|
this.templateRepository.findOne({
|
||||||
|
where: { id: binding.templateId, isDeleted: false },
|
||||||
}),
|
}),
|
||||||
this.templateRepository.findOne({ where: { id: binding.templateId } }),
|
|
||||||
this.targetRepository.find({
|
this.targetRepository.find({
|
||||||
order: { createTime: 'ASC', id: 'ASC' },
|
order: { createTime: 'ASC', id: 'ASC' },
|
||||||
where: { bindingId: String(binding.id), isDeleted: false },
|
where: { bindingId: String(binding.id), isDeleted: false },
|
||||||
@ -573,7 +581,7 @@ export class QqbotAccountMessagePushService {
|
|||||||
|
|
||||||
/** Signals that a target binding is missing, deleted, or owned by another account. */
|
/** Signals that a target binding is missing, deleted, or owned by another account. */
|
||||||
private throwBindingUnavailable(): never {
|
private throwBindingUnavailable(): never {
|
||||||
throw new SystemMessageContractError('binding_invalid');
|
throw new SystemMessageContractError('binding_disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recognizes only MySQL's duplicate-key conflict used as final concurrency authority. */
|
/** Recognizes only MySQL's duplicate-key conflict used as final concurrency authority. */
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
type MessageSubscriptionView,
|
type MessageSubscriptionView,
|
||||||
} from '../../contract/message-push/qqbot-message-push.types';
|
} from '../../contract/message-push/qqbot-message-push.types';
|
||||||
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||||
|
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||||
import { SystemMessageSourceRegistry } from './system-message-source.registry';
|
import { SystemMessageSourceRegistry } from './system-message-source.registry';
|
||||||
|
|
||||||
const DEFAULT_PAGE_NO = 1;
|
const DEFAULT_PAGE_NO = 1;
|
||||||
@ -154,6 +155,9 @@ export class QqbotMessageSubscriptionService {
|
|||||||
async (manager) => {
|
async (manager) => {
|
||||||
const repository = manager.getRepository(QqbotMessageSubscription);
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
const current = await this.findActiveForWrite(repository, id);
|
const current = await this.findActiveForWrite(repository, id);
|
||||||
|
if (current.sourceKey !== normalized.sourceKey) {
|
||||||
|
await this.assertNoLiveBindings(manager, id);
|
||||||
|
}
|
||||||
const conflict = await repository.findOne({
|
const conflict = await repository.findOne({
|
||||||
where: { activeKey: normalized.activeKey, isDeleted: false },
|
where: { activeKey: normalized.activeKey, isDeleted: false },
|
||||||
});
|
});
|
||||||
@ -215,6 +219,7 @@ export class QqbotMessageSubscriptionService {
|
|||||||
async (manager) => {
|
async (manager) => {
|
||||||
const repository = manager.getRepository(QqbotMessageSubscription);
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
const current = await this.findActiveForWrite(repository, id);
|
const current = await this.findActiveForWrite(repository, id);
|
||||||
|
await this.assertNoLiveBindings(manager, id);
|
||||||
current.activeKey = null;
|
current.activeKey = null;
|
||||||
current.enabled = false;
|
current.enabled = false;
|
||||||
current.isDeleted = true;
|
current.isDeleted = true;
|
||||||
@ -325,6 +330,21 @@ export class QqbotMessageSubscriptionService {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rejects a dependency lifecycle mutation while any enabled or disabled live binding references it. */
|
||||||
|
private async assertNoLiveBindings(
|
||||||
|
manager: EntityManager,
|
||||||
|
subscriptionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const count = await manager
|
||||||
|
.getRepository(QqbotMessagePublishBinding)
|
||||||
|
.count({
|
||||||
|
where: { isDeleted: false, subscriptionId },
|
||||||
|
});
|
||||||
|
if (count > 0) {
|
||||||
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps duplicate natural keys to the existing Vben-compatible HTTP conflict response.
|
* Maps duplicate natural keys to the existing Vben-compatible HTTP conflict response.
|
||||||
* @returns Never returns because it always throws an HTTP 409 exception.
|
* @returns Never returns because it always throws an HTTP 409 exception.
|
||||||
|
|||||||
@ -35,7 +35,8 @@ export class QqbotMessageTargetOptionsService {
|
|||||||
];
|
];
|
||||||
const unique = new Map<string, QqbotMessagePushTargetOption>();
|
const unique = new Map<string, QqbotMessagePushTargetOption>();
|
||||||
options.forEach((option) => {
|
options.forEach((option) => {
|
||||||
unique.set(`${option.targetType}:${option.targetId}`, option);
|
const key = `${option.targetType}:${option.targetId}`;
|
||||||
|
unique.set(key, this.preferCandidate(unique.get(key), option));
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
@ -98,6 +99,22 @@ export class QqbotMessageTargetOptionsService {
|
|||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Selects a stable duplicate candidate, retaining known names before lexically ordering conflicts. */
|
||||||
|
private preferCandidate(
|
||||||
|
current: QqbotMessagePushTargetOption | undefined,
|
||||||
|
candidate: QqbotMessagePushTargetOption,
|
||||||
|
): QqbotMessagePushTargetOption {
|
||||||
|
if (!current) return candidate;
|
||||||
|
const currentKnown = current.label !== current.targetId;
|
||||||
|
const candidateKnown = candidate.label !== candidate.targetId;
|
||||||
|
if (currentKnown !== candidateKnown) {
|
||||||
|
return candidateKnown ? candidate : current;
|
||||||
|
}
|
||||||
|
return candidate.label.localeCompare(current.label) < 0
|
||||||
|
? candidate
|
||||||
|
: current;
|
||||||
|
}
|
||||||
|
|
||||||
/** Produces the stable HTTP-safe empty candidate response. */
|
/** Produces the stable HTTP-safe empty candidate response. */
|
||||||
private unavailable(
|
private unavailable(
|
||||||
reasonCode: string,
|
reasonCode: string,
|
||||||
|
|||||||
@ -103,10 +103,32 @@ export class QqbotMessageTemplateService {
|
|||||||
id: string,
|
id: string,
|
||||||
input: MessageTemplateInput,
|
input: MessageTemplateInput,
|
||||||
): Promise<MessageTemplateView> {
|
): Promise<MessageTemplateView> {
|
||||||
const current = await this.findActive(id);
|
|
||||||
this.validateInput(input);
|
this.validateInput(input);
|
||||||
Object.assign(current, this.toPersistenceInput(input));
|
const saved = await this.templateRepository.manager.transaction(
|
||||||
return this.toView(await this.templateRepository.save(current));
|
/** Holds the dependency row through reference counting and its source-safe save. */
|
||||||
|
async (manager) => {
|
||||||
|
const templateRepository = manager.getRepository(QqbotMessageTemplate);
|
||||||
|
const bindingRepository = manager.getRepository(
|
||||||
|
QqbotMessagePublishBinding,
|
||||||
|
);
|
||||||
|
const current = await templateRepository.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id, isDeleted: false },
|
||||||
|
});
|
||||||
|
if (!current) throw new SystemMessageContractError('template_invalid');
|
||||||
|
if (current.sourceKey !== input.sourceKey) {
|
||||||
|
const referenceCount = await bindingRepository.count({
|
||||||
|
where: { isDeleted: false, templateId: id },
|
||||||
|
});
|
||||||
|
if (referenceCount > 0) {
|
||||||
|
throw new SystemMessageContractError('template_invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.assign(current, this.toPersistenceInput(input));
|
||||||
|
return templateRepository.save(current);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.toView(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -160,4 +160,197 @@ describe('QqbotAccountMessagePushService', () => {
|
|||||||
});
|
});
|
||||||
expect(events).toEqual(['subscription', 'template', 'binding', 'targets']);
|
expect(events).toEqual(['subscription', 'template', 'binding', 'targets']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to a new binding when the selected historical row changes before its write lock', async () => {
|
||||||
|
const historical = {
|
||||||
|
accountId: 'account-1',
|
||||||
|
activeKey: null,
|
||||||
|
id: 'historical-1',
|
||||||
|
isDeleted: true,
|
||||||
|
selfId: '10001',
|
||||||
|
subscriptionId: 'subscription-1',
|
||||||
|
templateId: 'template-1',
|
||||||
|
updateTime: '2026-07-24',
|
||||||
|
};
|
||||||
|
const saved: any[] = [];
|
||||||
|
const subscription = {
|
||||||
|
enabled: true,
|
||||||
|
id: 'subscription-1',
|
||||||
|
isDeleted: false,
|
||||||
|
name: '订阅',
|
||||||
|
sourceConfig: {},
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
};
|
||||||
|
const template = {
|
||||||
|
content: '正文',
|
||||||
|
enabled: true,
|
||||||
|
id: 'template-1',
|
||||||
|
isDeleted: false,
|
||||||
|
name: '模板',
|
||||||
|
sourceKey: subscription.sourceKey,
|
||||||
|
};
|
||||||
|
const targets: any[] = [];
|
||||||
|
const bindingStore = {
|
||||||
|
create: jest.fn((value) => ({
|
||||||
|
...value,
|
||||||
|
createTime: '2026-07-24',
|
||||||
|
id: 'new-binding-1',
|
||||||
|
updateTime: '2026-07-24',
|
||||||
|
})),
|
||||||
|
findOne: jest.fn(async (options) => {
|
||||||
|
const where = options.where as Record<string, unknown>;
|
||||||
|
if ('activeKey' in where) return null;
|
||||||
|
if ('subscriptionId' in where && !options.lock) return historical;
|
||||||
|
if (options.lock?.mode === 'pessimistic_write') {
|
||||||
|
Object.assign(historical, {
|
||||||
|
accountId: 'account-2',
|
||||||
|
selfId: '10002',
|
||||||
|
subscriptionId: 'subscription-2',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error('unexpected binding lookup');
|
||||||
|
}),
|
||||||
|
save: jest.fn(async (value) => {
|
||||||
|
saved.push(value);
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const targetStore = {
|
||||||
|
create: jest.fn((value) => ({ ...value, id: 'target-1' })),
|
||||||
|
find: jest.fn(async () => []),
|
||||||
|
save: jest.fn(async (values) => {
|
||||||
|
targets.splice(0, targets.length, ...values);
|
||||||
|
return values;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: unknown) => {
|
||||||
|
if (entity === QqbotMessagePublishBinding) return bindingStore;
|
||||||
|
if (entity === QqbotMessagePublishTarget) return targetStore;
|
||||||
|
throw new Error('unexpected repository');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new QqbotAccountMessagePushService(
|
||||||
|
{
|
||||||
|
manager: { transaction: async (callback: any) => callback(manager) },
|
||||||
|
} as never,
|
||||||
|
{ find: async () => targets } as never,
|
||||||
|
{ findOne: async () => subscription } as never,
|
||||||
|
{ findOne: async () => template } as never,
|
||||||
|
{
|
||||||
|
findBySelfId: async () => ({ id: 'account-1', selfId: '10001' }),
|
||||||
|
} as never,
|
||||||
|
{ requireAvailableForBinding: async () => subscription } as never,
|
||||||
|
{ requireAvailableForBinding: async () => template } as never,
|
||||||
|
{
|
||||||
|
get: () => ({
|
||||||
|
definition: { displayName: 'STUN 映射', variables: [] },
|
||||||
|
inspectSubscription: async () => ({ valid: true }),
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ validate: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createBinding('10001', {
|
||||||
|
enabled: true,
|
||||||
|
subscriptionId: 'subscription-1',
|
||||||
|
templateId: 'template-1',
|
||||||
|
targets: [{ targetId: '20001', targetType: 'group' }],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ id: 'new-binding-1' }));
|
||||||
|
expect(bindingStore.findOne).toHaveBeenLastCalledWith({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: {
|
||||||
|
accountId: 'account-1',
|
||||||
|
id: 'historical-1',
|
||||||
|
isDeleted: true,
|
||||||
|
selfId: '10001',
|
||||||
|
subscriptionId: 'subscription-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saved).toEqual([
|
||||||
|
expect.objectContaining({ id: 'new-binding-1', isDeleted: false }),
|
||||||
|
]);
|
||||||
|
expect(saved[0].id).not.toBe(historical.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses binding_disabled for missing and stale binding mutation snapshots', async () => {
|
||||||
|
const unavailable = () =>
|
||||||
|
new QqbotAccountMessagePushService(
|
||||||
|
{
|
||||||
|
manager: {
|
||||||
|
transaction: async (callback: any) =>
|
||||||
|
callback({
|
||||||
|
getRepository: () => ({ findOne: async () => null }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
findBySelfId: async () => ({ id: 'account-1', selfId: '10001' }),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const input = {
|
||||||
|
enabled: false,
|
||||||
|
subscriptionId: 'subscription-1',
|
||||||
|
targets: [],
|
||||||
|
templateId: 'template-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
unavailable().updateBinding('10001', 'missing', input),
|
||||||
|
).rejects.toMatchObject({ code: 'binding_disabled' });
|
||||||
|
await expect(
|
||||||
|
unavailable().setBindingEnabled('10001', 'missing', true),
|
||||||
|
).rejects.toMatchObject({ code: 'binding_disabled' });
|
||||||
|
await expect(
|
||||||
|
unavailable().removeBinding('10001', 'missing'),
|
||||||
|
).rejects.toMatchObject({ code: 'binding_disabled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts deleted legacy dependency metadata from unavailable binding views', async () => {
|
||||||
|
const binding = {
|
||||||
|
accountId: 'account-1',
|
||||||
|
createTime: '2026-07-24',
|
||||||
|
enabled: false,
|
||||||
|
id: 'binding-1',
|
||||||
|
isDeleted: false,
|
||||||
|
selfId: '10001',
|
||||||
|
subscriptionId: 'subscription-1',
|
||||||
|
templateId: 'template-1',
|
||||||
|
updateTime: '2026-07-24',
|
||||||
|
};
|
||||||
|
const service = new QqbotAccountMessagePushService(
|
||||||
|
{ find: async () => [binding] } as never,
|
||||||
|
{ find: async () => [] } as never,
|
||||||
|
{ findOne: jest.fn(async () => null) } as never,
|
||||||
|
{ findOne: jest.fn(async () => null) } as never,
|
||||||
|
{
|
||||||
|
findBySelfId: async () => ({ id: 'account-1', selfId: '10001' }),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.listBindings('10001')).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
available: false,
|
||||||
|
invalidReasonCode: 'invalid_source_config',
|
||||||
|
sourceKey: '',
|
||||||
|
sourceName: '',
|
||||||
|
subscriptionName: '',
|
||||||
|
templateName: '',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { NetworkStunMessageSourceAdapter } from '../../../../src/modules/admin/p
|
|||||||
import { NetworkDdnsRecord } from '../../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
|
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 { 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 { 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 SOURCE_KEY = 'network.stun.mapping-port-changed';
|
||||||
const NOW = new KtDateTime('2026-07-24 08:09:10');
|
const NOW = new KtDateTime('2026-07-24 08:09:10');
|
||||||
@ -122,6 +123,8 @@ function registry(
|
|||||||
function setup(
|
function setup(
|
||||||
items: QqbotMessageSubscription[] = [],
|
items: QqbotMessageSubscription[] = [],
|
||||||
source: SystemMessageSourceAdapter = adapter(),
|
source: SystemMessageSourceAdapter = adapter(),
|
||||||
|
bindings: Array<{ isDeleted: boolean; subscriptionId: string }> = [],
|
||||||
|
additionalSources: SystemMessageSourceAdapter[] = [],
|
||||||
) {
|
) {
|
||||||
let createSequence = 1000;
|
let createSequence = 1000;
|
||||||
const subscriptionRepository = {
|
const subscriptionRepository = {
|
||||||
@ -194,20 +197,41 @@ function setup(
|
|||||||
return item;
|
return item;
|
||||||
}),
|
}),
|
||||||
} as unknown as jest.Mocked<Repository<QqbotMessageSubscription>>;
|
} 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 = {
|
const manager = {
|
||||||
getRepository: jest.fn((entity) => {
|
getRepository: jest.fn((entity) => {
|
||||||
if (entity === QqbotMessageSubscription) return subscriptionRepository;
|
if (entity === QqbotMessageSubscription) return subscriptionRepository;
|
||||||
|
if (entity === QqbotMessagePublishBinding) return bindingRepository;
|
||||||
throw new Error('unexpected repository');
|
throw new Error('unexpected repository');
|
||||||
}),
|
}),
|
||||||
} as unknown as jest.Mocked<EntityManager>;
|
} as unknown as jest.Mocked<EntityManager>;
|
||||||
Object.assign(subscriptionRepository, {
|
Object.assign(subscriptionRepository, {
|
||||||
manager: { transaction: jest.fn((callback) => callback(manager)) },
|
manager: { transaction: jest.fn((callback) => callback(manager)) },
|
||||||
});
|
});
|
||||||
|
const sourceRegistry = registry(source);
|
||||||
|
additionalSources.forEach((item) => sourceRegistry.register(item));
|
||||||
const service = new QqbotMessageSubscriptionService(
|
const service = new QqbotMessageSubscriptionService(
|
||||||
subscriptionRepository,
|
subscriptionRepository,
|
||||||
registry(source),
|
sourceRegistry,
|
||||||
);
|
);
|
||||||
return { items, manager, service, source, subscriptionRepository };
|
return {
|
||||||
|
bindingRepository,
|
||||||
|
items,
|
||||||
|
manager,
|
||||||
|
service,
|
||||||
|
source,
|
||||||
|
sourceRegistry,
|
||||||
|
subscriptionRepository,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -878,6 +902,44 @@ describe('QqbotMessageSubscriptionService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: '改源',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: 'other.source',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_source_config' });
|
||||||
|
await expect(
|
||||||
|
service.update('100', {
|
||||||
|
enabled: false,
|
||||||
|
name: '同源编辑',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ name: '同源编辑' }));
|
||||||
|
|
||||||
|
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 () => {
|
it('makes a binding transaction observe deletion after the shared subscription row lock commits', async () => {
|
||||||
const current = subscription();
|
const current = subscription();
|
||||||
const deleteHasLock = deferred();
|
const deleteHasLock = deferred();
|
||||||
@ -910,6 +972,9 @@ describe('QqbotMessageSubscriptionService', () => {
|
|||||||
const manager = {
|
const manager = {
|
||||||
getRepository: jest.fn((entity) => {
|
getRepository: jest.fn((entity) => {
|
||||||
if (entity === QqbotMessageSubscription) return repository;
|
if (entity === QqbotMessageSubscription) return repository;
|
||||||
|
if (entity === QqbotMessagePublishBinding) {
|
||||||
|
return { count: jest.fn(async () => 0) };
|
||||||
|
}
|
||||||
throw new Error('unexpected repository');
|
throw new Error('unexpected repository');
|
||||||
}),
|
}),
|
||||||
} as unknown as EntityManager;
|
} as unknown as EntityManager;
|
||||||
|
|||||||
@ -98,4 +98,55 @@ describe('QqbotMessageTargetOptionsService', () => {
|
|||||||
reasonCode: 'onebot_unavailable',
|
reasonCode: 'onebot_unavailable',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates equivalent OneBot candidates deterministically without discarding known names', async () => {
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{ group_id: '20000000000000001' },
|
||||||
|
{ group_id: '20000000000000001', group_name: 'Beta' },
|
||||||
|
{ group_id: '20000000000000001', group_name: 'Alpha' },
|
||||||
|
],
|
||||||
|
status: 'ok',
|
||||||
|
},
|
||||||
|
{ data: [], status: 'ok' },
|
||||||
|
];
|
||||||
|
const accountService = {
|
||||||
|
findBySelfId: jest.fn().mockResolvedValue({ id: '1' }),
|
||||||
|
};
|
||||||
|
const reverseWsService = {
|
||||||
|
sendAction: jest.fn(async () => responses.shift()),
|
||||||
|
};
|
||||||
|
const service = new QqbotMessageTargetOptionsService(
|
||||||
|
accountService as never,
|
||||||
|
reverseWsService as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await service.listTargetOptions('10001');
|
||||||
|
responses.push(
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{ group_id: '20000000000000001', group_name: 'Alpha' },
|
||||||
|
{ group_id: '20000000000000001', group_name: 'Beta' },
|
||||||
|
{ group_id: '20000000000000001' },
|
||||||
|
],
|
||||||
|
status: 'ok',
|
||||||
|
},
|
||||||
|
{ data: [], status: 'ok' },
|
||||||
|
);
|
||||||
|
const second = await service.listTargetOptions('10001');
|
||||||
|
|
||||||
|
expect(first).toEqual({
|
||||||
|
available: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'Alpha (20000000000000001)',
|
||||||
|
targetId: '20000000000000001',
|
||||||
|
targetType: 'group',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reasonCode: null,
|
||||||
|
});
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -169,13 +169,20 @@ function setup(
|
|||||||
transaction: jest.fn((callback) => callback(manager)),
|
transaction: jest.fn((callback) => callback(manager)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const sourceRegistry = registry();
|
||||||
const service = new QqbotMessageTemplateService(
|
const service = new QqbotMessageTemplateService(
|
||||||
templateRepository,
|
templateRepository,
|
||||||
bindingRepository,
|
bindingRepository,
|
||||||
registry(),
|
sourceRegistry,
|
||||||
new SystemMessageTemplateRendererService(),
|
new SystemMessageTemplateRendererService(),
|
||||||
);
|
);
|
||||||
return { bindingRepository, manager, service, templateRepository };
|
return {
|
||||||
|
bindingRepository,
|
||||||
|
manager,
|
||||||
|
service,
|
||||||
|
sourceRegistry,
|
||||||
|
templateRepository,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('QqbotMessageTemplateService', () => {
|
describe('QqbotMessageTemplateService', () => {
|
||||||
@ -297,6 +304,42 @@ describe('QqbotMessageTemplateService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('locks template source changes through reference counting but permits same-source edits', async () => {
|
||||||
|
const { bindingRepository, service, sourceRegistry, templateRepository } =
|
||||||
|
setup([template()], [binding({ enabled: false })]);
|
||||||
|
sourceRegistry.register({
|
||||||
|
...registry().get(SOURCE_KEY),
|
||||||
|
definition: {
|
||||||
|
...registry().get(SOURCE_KEY).definition,
|
||||||
|
sourceKey: 'other.source',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
content: '端口 ${{port}},就绪:${{ready}}',
|
||||||
|
enabled: false,
|
||||||
|
name: '改源',
|
||||||
|
sourceKey: 'other.source',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.update('100', input)).rejects.toMatchObject({
|
||||||
|
code: 'template_invalid',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.update('100', {
|
||||||
|
...input,
|
||||||
|
name: '同源编辑',
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ name: '同源编辑' }));
|
||||||
|
expect(templateRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: '100', isDeleted: false },
|
||||||
|
});
|
||||||
|
expect(bindingRepository.count).toHaveBeenCalledWith({
|
||||||
|
where: { isDeleted: false, templateId: '100' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks soft deletion when any non-deleted binding references the template, including disabled bindings', async () => {
|
it('blocks soft deletion when any non-deleted binding references the template, including disabled bindings', async () => {
|
||||||
const { bindingRepository, manager, service, templateRepository } = setup();
|
const { bindingRepository, manager, service, templateRepository } = setup();
|
||||||
bindingRepository.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
|
bindingRepository.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user