import { RiskReviewService } from './risk-review.service'; function createPrismaMock(overrides: Record = {}) { return { riskRule: { findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }), create: jest.fn(), findMany: jest.fn().mockResolvedValue([]), }, globalBlacklist: { findMany: jest.fn().mockResolvedValue([]), }, enterpriseBlacklist: { findMany: jest.fn().mockResolvedValue([]), }, sensitiveWord: { findMany: jest.fn().mockResolvedValue([]), }, user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1' }), }, smsApplication: { findUnique: jest.fn().mockResolvedValue(null), }, smsTemplate: { findUnique: jest.fn().mockResolvedValue(null), }, smsSendTask: { count: jest.fn().mockResolvedValue(0), create: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'risk-task-1', ...data }), ), findUnique: jest.fn().mockResolvedValue({ id: 'risk-task-1', riskHits: [] }), update: jest.fn(), findMany: jest.fn(), upsert: jest.fn().mockImplementation(({ create }: { create: Record }) => Promise.resolve({ id: 'review-task-1', ...create })), }, smsMessageRecord: { update: jest.fn().mockResolvedValue({ id: 'message-1' }), }, riskHitRecord: { createMany: jest.fn(), findMany: jest.fn(), }, $transaction: jest.fn(async (callback) => callback({ smsSendTask: { upsert: jest.fn().mockImplementation(({ create }: { create: Record }) => Promise.resolve({ id: 'review-task-1', ...create })), }, smsMessageRecord: { update: jest.fn().mockResolvedValue({ id: 'message-1' }), }, })), ...overrides, }; } describe('RiskReviewService', () => { it('groups identical CMPP template mismatches into a deterministic short review window', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1', sourceType: 'cmpp_template_mismatch', phoneTotal: 2, _count: { messageRecords: 2 }, riskHits: [], }); const service = new RiskReviewService(prisma as never); const result = await service.aggregateTemplateMismatch({ tenantId: 'tenant-1', applicationId: 'app-1', account: '100001', messageRecordId: 'message-1', signatureId: 'sig-1', content: '【签名】同一审核内容', }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(result).toEqual(expect.objectContaining({ sourceType: 'cmpp_template_mismatch', phoneTotal: 2 })); }); it('does not allow an aggregated review task to be decided before its window closes', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1', sourceType: 'cmpp_template_mismatch', windowEndsAt: new Date(Date.now() + 10_000), }); const service = new RiskReviewService(prisma as never); await expect(service.approveTask('review-task-1', { reason: '通过' })).rejects.toThrow('聚合窗口尚未关闭'); expect(prisma.smsSendTask.update).not.toHaveBeenCalled(); }); it('batch rejects unique tasks with one required rejection reason', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve({ id: where.id, status: 'pending_review', reviewReason: '命中风控', })); prisma.smsSendTask.update.mockImplementation(({ where, data }: { where: { id: string }; data: Record }) => Promise.resolve({ id: where.id, ...data, riskHits: [], })); const service = new RiskReviewService(prisma as never); await expect(service.rejectTasks({ ids: ['task-1', 'task-2', 'task-1'], reason: '批量人工拒绝' })).resolves.toEqual([ expect.objectContaining({ id: 'task-1', status: 'rejected', rejectReason: '批量人工拒绝' }), expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }), ]); expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2); }); it('requires task ids and a reason for batch rejection', async () => { const service = new RiskReviewService(createPrismaMock() as never); await expect(service.rejectTasks({ ids: [], reason: '拒绝' })).rejects.toThrow('At least one SMS send task id is required'); await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required'); }); it('rejects tasks over the application max phone threshold', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 }); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-max', code: 'MAX_PHONES_PER_TASK', name: '单任务最大号码数', metric: 'phoneTotal', thresholdValue: 100000, action: 'block', priority: 10, }, ]); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'risk-task-1', status: 'rejected', riskHits: [{ ruleCode: 'MAX_PHONES_PER_TASK' }], }); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: 'hello', phones: ['13800000001', '13800000002', '13800000003'], }); expect(result.canSubmit).toBe(false); expect(result.status).toBe('rejected'); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ ruleCode: 'MAX_PHONES_PER_TASK', actualValue: 3, action: 'block' })], }); }); it('routes duplicate and blacklist ratio hits to manual review', async () => { const prisma = createPrismaMock(); prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]); prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002' }]); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-dup', code: 'DUPLICATE_PHONE_RATIO', name: '重复号码比例', metric: 'duplicateRatio', thresholdValue: 0.2, action: 'manual_review', priority: 20, }, { id: 'rule-black', code: 'BLACKLIST_HIT_RATIO', name: '黑名单命中比例', metric: 'blacklistHitRatio', thresholdValue: 0.2, action: 'manual_review', priority: 40, }, ]); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: 'hello', phones: ['13800000001', '13800000001', '13800000002'], }); expect(result.status).toBe('pending_review'); expect(prisma.smsSendTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ duplicateRatio: 0.3333, blacklistHitRatio: 0.6667, status: 'pending_review', riskDecision: 'manual_review', }), }); expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: { in: ['13800000001', '13800000002'] }, status: 'active' }, select: { phoneNumber: true }, }); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }), expect.objectContaining({ ruleCode: 'BLACKLIST_HIT_RATIO' }), ]), }); }); it('rejects illegal phone and template variable anomalies', async () => { const prisma = createPrismaMock(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', category: 'notice', variables: [{ name: 'code', required: true }], }); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-illegal', code: 'ILLEGAL_PHONE_RATIO', name: '非法号码比例', metric: 'illegalRatio', thresholdValue: 0.1, action: 'block', priority: 30, }, { id: 'rule-var', code: 'TEMPLATE_VARIABLE_ANOMALY', name: '模板变量异常', metric: 'variableIssueCount', thresholdValue: 0, action: 'block', priority: 70, }, ]); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ tenantId: 'tenant-1', templateId: 'tpl-1', content: '验证码 ${code}', phones: ['13800000001', 'not-a-phone'], variables: { extra: 'value' }, }); expect(result.status).toBe('rejected'); expect(prisma.smsSendTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ illegalRatio: 0.5, variableIssues: { variables: expect.arrayContaining([ { type: 'missing_required_variable', name: 'code' }, { type: 'unexpected_variable', name: 'extra' }, ]), content: [], }, }), }); }); it('marks non-working marketing bulk and frequent task creation for manual review', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.count.mockResolvedValue(11); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-night', code: 'NON_WORKING_MARKETING_BULK', name: '非工作时间大批量营销发送', metric: 'nonWorkingMarketingPhones', thresholdValue: 2, action: 'manual_review', priority: 50, }, { id: 'rule-frequency', code: 'TASK_CREATE_FREQUENCY', name: '短时间任务创建频控', metric: 'recentTaskCount', thresholdValue: 10, action: 'manual_review', priority: 60, }, ]); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ tenantId: 'tenant-1', category: 'marketing', content: 'promo', phones: ['13800000001', '13800000002', '13800000003'], requestedAt: '2026-07-01T22:00:00+08:00', }); expect(result.status).toBe('pending_review'); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }), expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 11 }), ]), }); }); it('rejects sensitive words and illegal control characters before sending', async () => { const prisma = createPrismaMock(); prisma.sensitiveWord.findMany.mockResolvedValue([{ word: '违法词', level: 'block' }]); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ tenantId: 'tenant-1', content: '包含违法词\u0001', phones: ['13800000001'], }); expect(result.status).toBe('rejected'); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ ruleCode: 'CONTENT_CONTROL_CHAR', action: 'block' }), expect.objectContaining({ ruleCode: 'SENSITIVE_WORD', action: 'block' }), ]), }); }); it('returns a bad request for unknown optional creator ids', async () => { const prisma = createPrismaMock(); prisma.user.findUnique.mockResolvedValue(null); const service = new RiskReviewService(prisma as never); await expect( service.evaluateTask({ tenantId: 'tenant-1', content: 'hello', phones: ['13800000001'], createdById: 'missing-user', }), ).rejects.toThrow('createdById does not reference an existing user'); }); });