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); }); });