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