341 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|