fix: 保证消息发布绑定依赖一致性
This commit is contained in:
parent
b1e346bfd7
commit
bf14079bde
@ -103,7 +103,13 @@ export class QqbotAccountMessagePushService {
|
||||
if (historicalCandidate) {
|
||||
const historical = await bindings.findOne({
|
||||
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) {
|
||||
Object.assign(
|
||||
@ -364,9 +370,11 @@ export class QqbotAccountMessagePushService {
|
||||
): Promise<QqbotMessagePublishBindingView> {
|
||||
const [subscription, template, targets] = await Promise.all([
|
||||
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({
|
||||
order: { createTime: 'ASC', id: 'ASC' },
|
||||
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. */
|
||||
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. */
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
type MessageSubscriptionView,
|
||||
} from '../../contract/message-push/qqbot-message-push.types';
|
||||
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';
|
||||
|
||||
const DEFAULT_PAGE_NO = 1;
|
||||
@ -154,6 +155,9 @@ export class QqbotMessageSubscriptionService {
|
||||
async (manager) => {
|
||||
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||
const current = await this.findActiveForWrite(repository, id);
|
||||
if (current.sourceKey !== normalized.sourceKey) {
|
||||
await this.assertNoLiveBindings(manager, id);
|
||||
}
|
||||
const conflict = await repository.findOne({
|
||||
where: { activeKey: normalized.activeKey, isDeleted: false },
|
||||
});
|
||||
@ -215,6 +219,7 @@ export class QqbotMessageSubscriptionService {
|
||||
async (manager) => {
|
||||
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||
const current = await this.findActiveForWrite(repository, id);
|
||||
await this.assertNoLiveBindings(manager, id);
|
||||
current.activeKey = null;
|
||||
current.enabled = false;
|
||||
current.isDeleted = true;
|
||||
@ -325,6 +330,21 @@ export class QqbotMessageSubscriptionService {
|
||||
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.
|
||||
* @returns Never returns because it always throws an HTTP 409 exception.
|
||||
|
||||
@ -35,7 +35,8 @@ export class QqbotMessageTargetOptionsService {
|
||||
];
|
||||
const unique = new Map<string, QqbotMessagePushTargetOption>();
|
||||
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 {
|
||||
available: true,
|
||||
@ -98,6 +99,22 @@ export class QqbotMessageTargetOptionsService {
|
||||
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. */
|
||||
private unavailable(
|
||||
reasonCode: string,
|
||||
|
||||
@ -103,10 +103,32 @@ export class QqbotMessageTemplateService {
|
||||
id: string,
|
||||
input: MessageTemplateInput,
|
||||
): Promise<MessageTemplateView> {
|
||||
const current = await this.findActive(id);
|
||||
this.validateInput(input);
|
||||
Object.assign(current, this.toPersistenceInput(input));
|
||||
return this.toView(await this.templateRepository.save(current));
|
||||
const saved = await this.templateRepository.manager.transaction(
|
||||
/** 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']);
|
||||
});
|
||||
|
||||
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 { 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');
|
||||
@ -122,6 +123,8 @@ function registry(
|
||||
function setup(
|
||||
items: QqbotMessageSubscription[] = [],
|
||||
source: SystemMessageSourceAdapter = adapter(),
|
||||
bindings: Array<{ isDeleted: boolean; subscriptionId: string }> = [],
|
||||
additionalSources: SystemMessageSourceAdapter[] = [],
|
||||
) {
|
||||
let createSequence = 1000;
|
||||
const subscriptionRepository = {
|
||||
@ -194,20 +197,41 @@ function setup(
|
||||
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,
|
||||
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 () => {
|
||||
const current = subscription();
|
||||
const deleteHasLock = deferred();
|
||||
@ -910,6 +972,9 @@ describe('QqbotMessageSubscriptionService', () => {
|
||||
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;
|
||||
|
||||
@ -98,4 +98,55 @@ describe('QqbotMessageTargetOptionsService', () => {
|
||||
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)),
|
||||
},
|
||||
});
|
||||
const sourceRegistry = registry();
|
||||
const service = new QqbotMessageTemplateService(
|
||||
templateRepository,
|
||||
bindingRepository,
|
||||
registry(),
|
||||
sourceRegistry,
|
||||
new SystemMessageTemplateRendererService(),
|
||||
);
|
||||
return { bindingRepository, manager, service, templateRepository };
|
||||
return {
|
||||
bindingRepository,
|
||||
manager,
|
||||
service,
|
||||
sourceRegistry,
|
||||
templateRepository,
|
||||
};
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const { bindingRepository, manager, service, templateRepository } = setup();
|
||||
bindingRepository.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user