Files
lislgosms/api/src/risk-review/risk-review.service.spec.ts
T

509 lines
19 KiB
TypeScript

import { RiskReviewService } from './risk-review.service';
function createPrismaMock(overrides: Record<string, unknown> = {}) {
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<string, unknown> }) =>
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<string, unknown> }) => 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<string, unknown> }) => 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<string, unknown> }) => 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');
});
});