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

341 lines
12 KiB
TypeScript

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