182 lines
12 KiB
TypeScript
182 lines
12 KiB
TypeScript
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
|
|
|
|
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('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([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
|
|
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: '测试应用' }),
|
|
]));
|
|
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
|
|
});
|
|
|
|
it('persists daily observing snapshots without opening alert cycles', async () => {
|
|
const prisma = {
|
|
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
|
|
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
|
|
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
|
|
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
|
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
|
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
|
|
};
|
|
const observingService = new SignatureRetirementService(prisma as never);
|
|
|
|
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
|
|
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
|
|
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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,
|
|
});
|
|
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
|
const sql = query.strings?.join('?') ?? '';
|
|
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
|
|
expect(sql).toContain('message."signatureId" IS NULL');
|
|
expect(sql).toContain('FROM "SmsSignature" signature');
|
|
expect(sql).toContain('signature."applicationId" = extracted.application_id');
|
|
expect(sql).toContain('GROUP BY\n extracted.tenant_id,\n extracted.application_id,');
|
|
expect(sql).not.toContain('FROM "ChannelSignatureReportTask" report');
|
|
});
|
|
|
|
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,
|
|
});
|
|
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
|
expect(query.strings?.join('?')).toContain('signature."auditStatus" <> \'deleted\'');
|
|
});
|
|
|
|
it('does not count deleted signatures as unread retirement warnings', async () => {
|
|
const prisma = { $queryRaw: jest.fn().mockResolvedValue([{ count: 2 }]) };
|
|
const unreadService = new SignatureRetirementService(prisma as never);
|
|
|
|
await expect(unreadService.unreadCount()).resolves.toEqual({ count: 2 });
|
|
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
|
const sql = query.strings?.join('?') ?? '';
|
|
expect(sql).toContain('JOIN "SmsSignature" signature ON signature.id = detection."signatureId"');
|
|
expect(sql).toContain('signature."auditStatus" <> \'deleted\'');
|
|
});
|
|
|
|
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 } };
|
|
}
|