feat: support governed cascade deletion
This commit is contained in:
@@ -7,9 +7,13 @@ describe('DeletionGovernanceService', () => {
|
||||
function setup() {
|
||||
const tx = {
|
||||
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
||||
smsChannel: { updateMany: jest.fn() },
|
||||
smsSignature: { updateMany: jest.fn() },
|
||||
smsTemplate: { updateMany: jest.fn() },
|
||||
smsChannel: { findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
smsSignature: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||
smsTemplate: { findFirst: jest.fn(), updateMany: jest.fn() },
|
||||
smsDrainageInfo: { updateMany: jest.fn() },
|
||||
channelSignatureReportTask: { findMany: jest.fn(), update: jest.fn() },
|
||||
channelSignatureReportRecord: { create: jest.fn() },
|
||||
channelRouteRule: { findMany: jest.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
operationLog: { findFirst: jest.fn() },
|
||||
@@ -35,6 +39,42 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']);
|
||||
});
|
||||
|
||||
it('allows channel deletion with unfinished report tasks only after the cascade selection is confirmed', async () => {
|
||||
const { service, prisma, tx } = setup();
|
||||
const channel = {
|
||||
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
|
||||
groupItems: [], routeRules: [], connectionStates: [],
|
||||
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||
};
|
||||
prisma.smsChannel.findUnique.mockResolvedValue(channel);
|
||||
|
||||
const preflight = await service.preflight('channel', 'channel-1');
|
||||
|
||||
expect(preflight.allowedActions).toEqual(['delete']);
|
||||
expect(preflight.requiredSelections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'abandon_associated_report_tasks', count: 1 }),
|
||||
]));
|
||||
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsChannel.findUnique.mockResolvedValue(channel);
|
||||
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||
tx.smsChannel.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.smsSignature.findUnique.mockResolvedValue({ id: 'signature-1', applicationId: null, auditStatus: 'approved' });
|
||||
tx.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: 'channel-1', status: 'abandoned', channel: { id: 'channel-1', status: 'deleted' } }]);
|
||||
tx.smsSignature.update.mockResolvedValue({ id: 'signature-1' });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||
|
||||
await expect(service.delete('channel', 'channel-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-channel-1', abandonAssociatedReportTasks: true,
|
||||
})).resolves.toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }),
|
||||
}));
|
||||
expect(tx.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ data: { reportStatus: 'not_applicable' } }));
|
||||
});
|
||||
|
||||
it('returns an allowed template preflight scoped to the client tenant', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||
@@ -48,24 +88,53 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
|
||||
expect(result.allowedActions).toEqual(['delete']);
|
||||
expect(result.identity.tenant).toBe('示例企业');
|
||||
expect(result.requiredSelections).toEqual([]);
|
||||
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
include: expect.objectContaining({
|
||||
sendTasks: expect.objectContaining({ where: { status: { notIn: ['approved', 'rejected'] } } }),
|
||||
batchTasks: expect.objectContaining({ where: { status: { notIn: expect.arrayContaining(['finished', 'canceled']) } } }),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks signature deletion and exposes the referencing template and drainage items', async () => {
|
||||
it('turns signature dependencies into mandatory cascade selections', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板' }],
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
||||
});
|
||||
|
||||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||
|
||||
expect(result.allowedActions).toEqual([]);
|
||||
expect(result.allowedActions).toEqual(['delete']);
|
||||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }),
|
||||
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }),
|
||||
]));
|
||||
expect(result.requiredSelections.map((item) => item.action)).toEqual([
|
||||
'delete_associated_templates', 'delete_associated_drainage',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not expose report task ids or statuses to the client but still requires confirmation', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [], drainageItems: [], reportTasks: [{ id: 'internal-task-1', status: 'reporting' }],
|
||||
});
|
||||
|
||||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||
|
||||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'report_tasks', count: 1, items: [], detailsVisible: false }),
|
||||
]));
|
||||
expect(JSON.stringify(result)).not.toContain('internal-task-1');
|
||||
expect(result.requiredSelections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'abandon_associated_report_tasks' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('does not classify approved or abandoned report history as unfinished', async () => {
|
||||
@@ -91,10 +160,9 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(result.allowedActions).toEqual(['delete']);
|
||||
});
|
||||
|
||||
it('requires version, idempotency key and a meaningful reason', async () => {
|
||||
it('requires version and idempotency key but allows an omitted reason', async () => {
|
||||
const { service } = setup();
|
||||
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('soft deletes once and writes an auditable operation number', async () => {
|
||||
@@ -106,11 +174,14 @@ describe('DeletionGovernanceService', () => {
|
||||
sendTasks: [], batchTasks: [],
|
||||
});
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsTemplate.findFirst.mockResolvedValue({
|
||||
id: 'template-1', tenantId: 'tenant-1', sendTasks: [], batchTasks: [],
|
||||
});
|
||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||
|
||||
const result = await service.delete('template', 'template-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1',
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', operatorId: 'user-1',
|
||||
}, 'tenant-1');
|
||||
|
||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
@@ -118,6 +189,52 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
|
||||
});
|
||||
|
||||
it('cascades all selected signature dependencies in one transaction with task history', async () => {
|
||||
const { service, prisma, tx } = setup();
|
||||
const preflightItem = {
|
||||
id: 'signature-1', tenantId: 'tenant-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }],
|
||||
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||
};
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.smsDrainageInfo.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||
tx.smsSignature.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||
|
||||
const result = await service.delete('signature', 'signature-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-signature-1', operatorId: 'user-1',
|
||||
deleteAssociatedTemplates: true, deleteAssociatedDrainage: true, abandonAssociatedReportTasks: true,
|
||||
}, 'tenant-1');
|
||||
|
||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||||
expect(tx.smsDrainageInfo.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted', pendingReport: false } }));
|
||||
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'abandoned' }) }));
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ statusBefore: 'reporting', statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }) }));
|
||||
});
|
||||
|
||||
it('rejects deletion until every discovered cascade selection is confirmed', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }], drainageItems: [], reportTasks: [],
|
||||
});
|
||||
|
||||
await expect(service.delete('signature', 'signature-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'missing-selection',
|
||||
}, 'tenant-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a stale optimistic-lock version', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
|
||||
Reference in New Issue
Block a user