import { RiskReviewService } from './risk-review.service'; function createPrismaMock(overrides: Record = {}) { return { riskRule: { count: jest.fn().mockResolvedValue(5), findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }), findUnique: jest.fn(), create: jest.fn(), update: 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), findMany: jest.fn().mockResolvedValue([]), }, smsSendTask: { 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' }), findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), }, smsBatchTask: { count: jest.fn().mockResolvedValue(0), }, 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('shares read-only rule inputs across an approved CMPP evaluation batch', async () => { const prisma = createPrismaMock(); const service = new RiskReviewService(prisma as never); const results = await service.evaluateTasksBatch([ { tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000001', phones: ['13800000001'], sourceType: 'cmpp' }, { tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000002', phones: ['13800000002'], sourceType: 'cmpp' }, ]); expect(results).toHaveLength(2); expect(results.every((result) => result.status === 'approved')).toBe(true); expect(prisma.riskRule.findMany).toHaveBeenCalledTimes(1); expect(prisma.sensitiveWord.findMany).toHaveBeenCalledTimes(1); expect(prisma.smsBatchTask.count).not.toHaveBeenCalled(); expect(prisma.smsSendTask.create).not.toHaveBeenCalled(); }); it('coalesces concurrent default-rule checks and reuses the short completeness cache', async () => { const prisma = createPrismaMock(); let releaseCount: ((count: number) => void) | undefined; prisma.riskRule.count.mockReturnValue(new Promise((resolve) => { releaseCount = resolve; })); const service = new RiskReviewService(prisma as never); const first = service.ensureDefaultRules(); const second = service.ensureDefaultRules(); releaseCount?.(5); await Promise.all([first, second]); await service.ensureDefaultRules(); expect(prisma.riskRule.count).toHaveBeenCalledTimes(1); expect(prisma.riskRule.findFirst).not.toHaveBeenCalled(); }); it('clears a failed default-rule check so the next request can retry', async () => { const prisma = createPrismaMock(); prisma.riskRule.count .mockRejectedValueOnce(new Error('database unavailable')) .mockResolvedValueOnce(5); const service = new RiskReviewService(prisma as never); await expect(service.ensureDefaultRules()).rejects.toThrow('database unavailable'); await expect(service.ensureDefaultRules()).resolves.toBeUndefined(); expect(prisma.riskRule.count).toHaveBeenCalledTimes(2); }); it('falls back to per-rule recovery when the completeness count finds a missing default', async () => { const prisma = createPrismaMock(); prisma.riskRule.count.mockResolvedValue(4); prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) => ( Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` }) )); const service = new RiskReviewService(prisma as never); service.createRule = jest.fn().mockResolvedValue({ id: 'restored-rule' }) as never; await service.ensureDefaultRules(); expect(prisma.riskRule.findFirst).toHaveBeenCalledTimes(5); expect(service.createRule).toHaveBeenCalledTimes(1); expect(service.createRule).toHaveBeenCalledWith(expect.objectContaining({ code: 'PHONE_FREQUENCY_5M' })); }); it('keeps phone-frequency periods fixed and rejects manual-review actions', () => { const service = new RiskReviewService(createPrismaMock() as never); expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 })) .toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改'); expect(() => service['validateRuleInput']({ code: 'PHONE_FREQUENCY_24H', thresholdValue: 10, action: 'manual_review', })).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); }); it('includes the sending enterprise and application in SMS review rows', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findMany.mockResolvedValue([]); const service = new RiskReviewService(prisma as never); await service.listTasks(undefined, 'pending_review'); expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ include: expect.objectContaining({ tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, }), })); }); it('filters SMS review tasks by their submission time', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findMany.mockResolvedValue([]); const service = new RiskReviewService(prisma as never); await service.listTasks(undefined, 'pending_review', '2026-08-01', '2026-08-03'); expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ createdAt: { gte: new Date('2026-08-01T00:00:00+08:00'), lte: new Date('2026-08-03T23:59:59.999+08:00'), }, }), })); }); 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 effective max phone rule threshold', async () => { const prisma = createPrismaMock(); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-max', code: 'MAX_PHONES_PER_TASK', name: '单任务最大号码数', metric: 'phoneTotal', thresholdValue: 2, 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('uses an application rule with the same code instead of the global default', async () => { const prisma = createPrismaMock(); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-global', applicationId: null, code: 'MAX_PHONES_PER_TASK', name: '单任务最大号码数', metric: 'phoneTotal', thresholdValue: 100000, action: 'block', priority: 10, }, { id: 'rule-app', applicationId: 'app-1', code: 'MAX_PHONES_PER_TASK', name: '单任务最大号码数', metric: 'phoneTotal', thresholdValue: 1, action: 'block', priority: 10, }, ]); const service = new RiskReviewService(prisma as never); await expect(service.evaluateTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: 'hello', phones: ['13800000001', '13800000002'], })).resolves.toEqual(expect.objectContaining({ status: 'rejected' })); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ ruleId: 'rule-app', thresholdValue: 1 })], }); }); it('paginates real phone records through both direct and batch review-task relations', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'message-1', phoneNumber: '13800000001', province: '上海', carrier: 'mobile', status: 'pending_review', }]); prisma.smsMessageRecord.count.mockResolvedValue(1); const service = new RiskReviewService(prisma as never); await expect(service.listTaskMessages('review-task-1', '138', 1, 20)).resolves.toEqual({ items: [expect.objectContaining({ phoneNumber: '13800000001' })], total: 1, page: 1, pageSize: 20, }); expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { OR: [ { reviewTaskId: 'review-task-1' }, { batchTask: { riskTaskId: 'review-task-1' } }, ], phoneNumber: { contains: '138' }, }, skip: 0, take: 20, })); }); it('does not create an audit task for automatic approval', async () => { const prisma = createPrismaMock(); prisma.riskRule.findMany.mockResolvedValue([]); 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).toEqual(expect.objectContaining({ status: 'approved', canSubmit: true, task: null })); expect(prisma.smsSendTask.create).not.toHaveBeenCalled(); expect(prisma.globalBlacklist.findMany).not.toHaveBeenCalled(); expect(prisma.enterpriseBlacklist.findMany).not.toHaveBeenCalled(); }); it('keeps template variable validation as deterministic rejection instead of a configurable rule', async () => { const prisma = createPrismaMock(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', category: 'notice', variables: [{ name: 'code', required: true }], }); prisma.riskRule.findMany.mockResolvedValue([]); 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: expect.arrayContaining([ expect.objectContaining({ ruleCode: 'TEMPLATE_VARIABLE_INVALID', action: 'block' }), ]), }, }), }); }); it('marks non-working marketing bulk and frequent task creation for manual review', async () => { const prisma = createPrismaMock(); prisma.smsBatchTask.count.mockResolvedValue(10); 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', applicationId: 'app-1', sourceType: 'client', }); 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: 10 }), ]), }); expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({ where: { applicationId: 'app-1', sourceType: 'client', createdAt: { gte: expect.any(Date) }, }, }); }); it('does not include CMPP or HTTP tasks in client task frequency control', async () => { const prisma = createPrismaMock(); prisma.riskRule.findMany.mockResolvedValue([{ id: 'rule-frequency', code: 'TASK_CREATE_FREQUENCY', name: '短时间任务创建频控', metric: 'recentTaskCount', thresholdValue: 1, action: 'manual_review', priority: 30, }]); const service = new RiskReviewService(prisma as never); await expect(service.evaluateTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: 'hello', phones: ['10000000000'], sourceType: 'cmpp', })).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); await expect(service.evaluateTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: 'hello', phones: ['10000000000'], sourceType: 'api', })).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); expect(prisma.smsBatchTask.count).not.toHaveBeenCalled(); }); 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'); }); });