feat: add carrier-aware signature retirement alerts
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('SignatureRetirementService dimensions', () => {
|
||||
const service = new SignatureRetirementService({} as never);
|
||||
|
||||
it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => {
|
||||
const rules = [
|
||||
rule('enterprise_global', ''),
|
||||
rule('enterprise_application', 'app-1'),
|
||||
rule('channel_global', ''),
|
||||
rule('channel', 'channel-2'),
|
||||
];
|
||||
const tasks = [
|
||||
task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'),
|
||||
task('channel-2', '移动二号', 'mobile', '2026-06-05T00:00:00Z'),
|
||||
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
|
||||
];
|
||||
|
||||
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks);
|
||||
|
||||
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
|
||||
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
|
||||
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
|
||||
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
|
||||
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
|
||||
});
|
||||
|
||||
it('does not monitor legacy carrier-null reporting facts', () => {
|
||||
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
|
||||
[rule('enterprise_global', ''), rule('channel_global', '')],
|
||||
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
|
||||
);
|
||||
expect(dimensions).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an approved legacy carrier confirmation without an explicit valid approval time', async () => {
|
||||
await expect(service.confirmLegacyReport('legacy-1', {
|
||||
results: [{ carrier: 'mobile', status: 'approved' }],
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.confirmLegacyReport('legacy-1', {
|
||||
results: [{ carrier: 'mobile', status: 'approved', approvedAt: 'not-a-date' }],
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T23:30:00.000Z'), 8)).toBe(30 * 60_000);
|
||||
});
|
||||
|
||||
it('publishes frozen alert content only in the notification phase', async () => {
|
||||
const detection = {
|
||||
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
|
||||
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
|
||||
};
|
||||
const prisma = {
|
||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
|
||||
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
|
||||
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
|
||||
};
|
||||
const notificationService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
|
||||
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
|
||||
});
|
||||
|
||||
it('returns enterprise application metadata for heatmap hover and search', async () => {
|
||||
const prisma = {
|
||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
tenant: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
|
||||
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
|
||||
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
|
||||
channel: { name: '移动通道' },
|
||||
}]) },
|
||||
};
|
||||
const heatmapService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
const result = await heatmapService.heatmap('2026-08-10');
|
||||
|
||||
expect(result.dimensions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
|
||||
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('maps the real unreported-signature aggregation to an independent page', async () => {
|
||||
const prisma = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{
|
||||
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
|
||||
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
|
||||
}]),
|
||||
};
|
||||
const unreportedService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
|
||||
date: '2026-08-10',
|
||||
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
|
||||
total: 3,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a filtered historical message page with application metadata', async () => {
|
||||
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
|
||||
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
|
||||
const prisma = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
|
||||
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
|
||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([detection]) },
|
||||
smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'signature-1', name: '测试签名' }]) },
|
||||
smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '测试通道' }]) },
|
||||
tenant: { findMany: jest.fn().mockResolvedValue([{ id: 'tenant-1', name: '测试企业' }]) },
|
||||
smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', name: '测试应用' }]) },
|
||||
};
|
||||
const messageService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
|
||||
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
|
||||
total: 21,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a reason for temporary and permanent suppression', async () => {
|
||||
const prisma = {
|
||||
signatureRetirementMessage: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
||||
};
|
||||
const suppressionService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
|
||||
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
|
||||
});
|
||||
});
|
||||
|
||||
function rule(ruleType: string, targetKey: string) {
|
||||
return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 };
|
||||
}
|
||||
|
||||
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
|
||||
return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } };
|
||||
}
|
||||
Reference in New Issue
Block a user