250 lines
14 KiB
TypeScript
250 lines
14 KiB
TypeScript
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||
import { DeletionGovernanceService } from './deletion-governance.service';
|
||
|
||
describe('DeletionGovernanceService', () => {
|
||
const now = new Date('2026-07-21T10:00:00.000Z');
|
||
|
||
function setup() {
|
||
const tx = {
|
||
operationLog: { findFirst: jest.fn(), create: 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() },
|
||
smsChannel: { findUnique: jest.fn() },
|
||
smsSignature: { findFirst: jest.fn() },
|
||
smsTemplate: { findFirst: jest.fn() },
|
||
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
||
};
|
||
return { service: new DeletionGovernanceService(prisma as never), prisma, tx };
|
||
}
|
||
|
||
it('blocks channel deletion when a live group still references it', async () => {
|
||
const { service, prisma } = setup();
|
||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
|
||
groupItems: [{ priority: 10, group: { name: '移动主通道组' } }], routeRules: [], connectionStates: [], reportTasks: [],
|
||
});
|
||
|
||
const result = await service.preflight('channel', 'channel-1');
|
||
|
||
expect(result.allowedActions).toEqual([]);
|
||
expect(result.blockedReasons).toContain('引用该通道的通道组共 1 项,请先解除或完成');
|
||
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({
|
||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
|
||
});
|
||
|
||
const result = await service.preflight('template', 'template-1', 'tenant-1');
|
||
|
||
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.not.objectContaining({ sendTasks: expect.anything(), batchTasks: expect.anything() }),
|
||
}));
|
||
expect(result.impacts).toContain('已创建任务继续使用保存的内容快照');
|
||
});
|
||
|
||
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: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
||
});
|
||
|
||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||
|
||
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 () => {
|
||
const { service, prisma } = setup();
|
||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||
templates: [], drainageItems: [], reportTasks: [],
|
||
});
|
||
|
||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||
|
||
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||
include: expect.objectContaining({
|
||
reportTasks: expect.objectContaining({
|
||
where: { status: { notIn: expect.arrayContaining(['approved', 'abandoned']) } },
|
||
}),
|
||
}),
|
||
}));
|
||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ kind: 'report_tasks', count: 0 }),
|
||
]));
|
||
expect(result.allowedActions).toEqual(['delete']);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('soft deletes once and writes an auditable operation number', async () => {
|
||
const { service, prisma, tx } = setup();
|
||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||
});
|
||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||
tx.smsTemplate.findFirst.mockResolvedValue({
|
||
id: 'template-1', tenantId: 'tenant-1',
|
||
});
|
||
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', operatorId: 'user-1',
|
||
}, 'tenant-1');
|
||
|
||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||
expect(tx.smsTemplate.findFirst).toHaveBeenCalledWith({
|
||
where: { id: 'template-1', tenantId: 'tenant-1' },
|
||
select: { id: true, tenantId: true },
|
||
});
|
||
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||
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);
|
||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||
});
|
||
await expect(service.delete('template', 'template-1', {
|
||
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
|
||
}, 'tenant-1')).rejects.toBeInstanceOf(ConflictException);
|
||
});
|
||
});
|