Files
lislgosms/api/src/signature-retirement/signature-retirement.service.spec.ts
T

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 } };
}