import { BadRequestException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { SmsConfigService } from './sms-config.service'; function createPrismaMock() { return { $executeRaw: jest.fn().mockResolvedValue(1), smsApplication: { findMany: jest.fn().mockResolvedValue([ { id: 'app-1', tenantId: 'tenant-1', name: '应用A', status: 'active', cmppAccount: '100001', cmppEnterpriseCode: 'APP-EC', cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', cmppClientSrcId: '000001', cmppMaxConnections: 2, cmppWindowSize: 32, interfaceEnabled: true, interfaceType: 'cmpp20', queuePriority: 'normal', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }, ]), findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', name: '应用A', status: 'active', cmppAccount: '100001', cmppEnterpriseCode: 'APP-EC', cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', cmppClientSrcId: '000001', cmppMaxConnections: 2, cmppWindowSize: 32, interfaceEnabled: true, interfaceType: 'cmpp20', queuePriority: 'normal', secretHash: '0123456789abcdef', ipAllowlist: [], tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }), update: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })), }, smsApplicationIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), }, smsChannelGroup: { findMany: jest.fn().mockResolvedValue([ { id: 'group-mobile', carrier: 'mobile' }, { id: 'group-unicom', carrier: 'unicom' }, ]), }, channelRouteRule: { deleteMany: jest.fn().mockResolvedValue({ count: 2 }), createMany: jest.fn().mockResolvedValue({ count: 2 }), findMany: jest.fn().mockResolvedValue([ { id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active', }, { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active', }, ]), }, commonReportField: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), }, smsSignature: { groupBy: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([ { id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '行业通知', auditStatus: 'pending', drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] }, tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, application: { id: 'app-1', name: '应用A' }, materials: [], drainageItems: [], reportTasks: [], }, ]), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', auditStatus: 'pending', }), create: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })), update: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, signatureReportMaterial: { upsert: jest.fn().mockResolvedValue({ id: 'signature-report-value-1' }), }, drainageReportMaterial: { upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }), create: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), }, smsDrainageInfo: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data, }), ), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data, }), ), }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }), }, smsTemplate: { count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([ { id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '模板A', content: '您好${name}', auditStatus: 'pending', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, application: { id: 'app-1', name: '应用A' }, signature: { id: 'sig-1', name: '签名A' }, variables: [{ name: 'name', required: true }], }, ]), findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: '【签名A】您好${name}', auditStatus: 'pending', }), update: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })), }, auditRecord: { create: jest.fn(), findMany: jest.fn(), }, user: { findUnique: jest.fn().mockResolvedValue(null), }, cmppConnectionState: { findMany: jest.fn().mockResolvedValue([ { id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'connected', currentConnections: 1, desiredConnections: 1, }, ]), findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })), }, cmppDownstreamConnection: { findMany: jest.fn().mockResolvedValue([ { id: 'downstream-1', applicationId: 'app-1', tenantId: 'tenant-1', account: '100001', enterpriseCode: 'APP-EC', connectionId: 'gateway-1-1', status: 'connected', connectedAt: new Date(), lastHeartbeatAt: new Date(), }, ]), findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), upsert: jest .fn() .mockImplementation(({ create, update }) => Promise.resolve({ id: 'downstream-1', ...(create ?? update) })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), count: jest.fn().mockResolvedValue(1), }, smsMessageRecord: { groupBy: jest.fn().mockResolvedValue([ { applicationId: 'app-1', status: 'delivered', _count: { _all: 1 } }, { applicationId: 'app-1', status: 'undelivered', _count: { _all: 1 } }, ]), count: jest.fn().mockResolvedValue(0), }, cmppDownstreamDelivery: { count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([]), updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, cmppDownstreamDeliveryAttempt: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'] }]), findFirst: jest.fn().mockResolvedValue({ id: 'channel-1', gatewayHost: '127.0.0.1', gatewayPort: 17890, enterpriseCode: 'EC', account: 'sp', passwordCipher: 'cipher', srcId: '10690000', cmppVersion: '3.0', config: { maxConnections: 2 }, }), }, operationLog: { create: jest.fn(), }, tenant: { findUnique: jest.fn().mockResolvedValue({ code: 'TENANT-A' }), }, $transaction: jest.fn((callback) => callback({ smsApplication: { update: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), }, smsApplicationIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), }, channelRouteRule: { deleteMany: jest.fn().mockResolvedValue({ count: 2 }), createMany: jest.fn().mockResolvedValue({ count: 2 }), findMany: jest.fn().mockResolvedValue([ { id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active', }, { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active', }, ]), }, templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), }, smsTemplate: { update: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), }, }), ), }; } describe('SmsConfigService', () => { it('rejects unknown reviewer ids before writing audit records', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect(service.approveSignature('sig-1', { reviewerId: 'missing-user' })).rejects.toThrow( 'reviewerId does not reference an existing user', ); expect(prisma.smsSignature.update).not.toHaveBeenCalled(); expect(prisma.auditRecord.create).not.toHaveBeenCalled(); }); it('lists enterprise applications with real CMPP connection state', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.listApplications({ includeConnections: true, enterpriseKeyword: '租户', applicationKeyword: '应用', status: 'active', }), ).resolves.toEqual([ expect.objectContaining({ id: 'app-1', cmppStatus: 'connected', queuePriority: 'normal', sentToday: 2, deliveryRate: 50, cmppConnections: [expect.objectContaining({ connectionId: 'gateway-1-1', account: '100001' })], }), ]); expect(prisma.smsApplication.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' }, }), include: { tenant: true, ipAllowlist: true, httpConfig: true }, omit: { secretHash: true }, }), ); const applicationModel = Prisma.dmmf.datamodel.models.find((model) => model.name === 'SmsApplication'); const applicationFields = new Set(applicationModel?.fields.map((field) => field.name)); const omittedFields = Object.keys(prisma.smsApplication.findMany.mock.calls[0][0].omit); expect(omittedFields.every((field) => applicationFields.has(field))).toBe(true); expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take'); expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith( expect.objectContaining({ by: ['applicationId', 'status'], _count: { _all: true }, }), ); }); it('moves an application with outstanding receipts into disabling for 72 hours', async () => { const prisma = createPrismaMock(); prisma.smsMessageRecord.count.mockResolvedValue(1); const service = new SmsConfigService(prisma as never); const result = await service.changeApplicationStatus('app-1', { status: 'disabled', reason: '运营端停用', }); expect(result).toEqual( expect.objectContaining({ status: 'disabling', autoDisableAt: expect.any(Date), deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }), }), ); expect(prisma.smsApplication.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'app-1' }, data: expect.objectContaining({ status: 'disabling', disablingAt: expect.any(Date), autoDisableAt: expect.any(Date), }), }), ); }); it('automatically disables and abandons outstanding deliveries after 72 hours', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findMany.mockResolvedValue([ { id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'disabling', autoDisableAt: new Date(Date.now() - 1_000), }, ]); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'disabling', disablingAt: new Date(Date.now() - 73 * 60 * 60 * 1_000), autoDisableAt: new Date(Date.now() - 1_000), disableReason: '等待清算', }); prisma.smsMessageRecord.count.mockResolvedValue(1); prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1' }]); prisma.cmppDownstreamDelivery.updateMany.mockResolvedValue({ count: 1 }); const originalFetch = global.fetch; global.fetch = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue('{"account":"100001","disconnected":2}'), }) as never; const service = new SmsConfigService(prisma as never); await service['runApplicationDisableScan'](); expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: 'app-1', status: 'disabling' }), data: expect.objectContaining({ status: 'disabled' }), }), ); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }), }), ); global.fetch = originalFetch; }); it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => { const prisma = createPrismaMock(); const baseApplication = { tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, ipAllowlist: [], }; prisma.smsApplication.findMany.mockResolvedValue([ { ...baseApplication, id: 'app-1', name: '乙应用' }, { ...baseApplication, id: 'app-2', name: '甲应用' }, { ...baseApplication, id: 'app-3', name: '丙应用' }, ]); prisma.cmppDownstreamConnection.findMany.mockResolvedValue([]); prisma.smsMessageRecord.groupBy.mockResolvedValue([ { applicationId: 'app-1', status: 'delivered', _count: { _all: 2 } }, { applicationId: 'app-2', status: 'delivered', _count: { _all: 2 } }, { applicationId: 'app-3', status: 'delivered', _count: { _all: 5 } }, ]); const service = new SmsConfigService(prisma as never); const applications = await service.listApplications({ includeConnections: true }); expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']); }); it('returns CMPP params from the public inbound Gateway config instead of an upstream channel', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); process.env.CMPP_PUBLIC_HOST = 'cmpp.example.com'; process.env.CMPP_PUBLIC_PORT = '17891'; await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual( expect.objectContaining({ applicationId: 'app-1', tenantName: '租户A', account: '100001', enterpriseCode: 'APP-EC', passwordCipher: '0123456789abcdef', srcId: '000001', applicationExtension: '0001', accessNumberFillEnabled: true, accessNumberFillPrefix: '00', gatewayHost: 'cmpp.example.com', gatewayPort: 17891, interfaceEnabled: true, interfaceType: 'cmpp20', maxConnections: 2, windowSize: 32, protocolVersion: 'CMPP2.0', }), ); expect(prisma.smsChannel.findFirst).not.toHaveBeenCalled(); delete process.env.CMPP_PUBLIC_HOST; delete process.env.CMPP_PUBLIC_PORT; }); it('creates enterprise applications with persisted queue priority', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValueOnce(null); const service = new SmsConfigService(prisma as never); await expect( service.createApplication({ tenantId: 'tenant-1', name: '优先应用', cmppAccount: '123456', cmppEnterpriseCode: 'CUSTOM-EC', passwordCipher: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, interfaceEnabled: false, interfaceType: 'cmpp20', queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'], }), ).resolves.toEqual(expect.objectContaining({ id: 'app-new' })); expect(prisma.smsApplication.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tenantId: 'tenant-1', name: '优先应用', cmppAccount: '123456', cmppEnterpriseCode: '123456', secretHash: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, interfaceEnabled: false, interfaceType: 'cmpp20', queuePriority: 'priority', dailyLimit: 100000, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, }), }), ); }); it('persists a filled client Src_Id separately from the real application extension', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(null); const service = new SmsConfigService(prisma as never); await service.createApplication({ tenantId: 'tenant-1', name: '接入号应用', cmppAccount: '123456', passwordCipher: '1234567890abcdef', cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', }); expect(prisma.smsApplication.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', cmppClientSrcId: '000001', }), }), ); }); it('rejects access number filling without a numeric prefix and application extension', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue(null); const service = new SmsConfigService(prisma as never); await expect( service.createApplication({ tenantId: 'tenant-1', name: '缺少扩展码', cmppAccount: '123456', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', }), ).rejects.toThrow('cmppApplicationExtension is required'); await expect( service.createApplication({ tenantId: 'tenant-1', name: '错误前缀', cmppAccount: '123456', cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: 'AB', }), ).rejects.toThrow('cmppAccessNumberFillPrefix must contain digits only'); }); it('rejects invalid enterprise application queue priority', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.createApplication({ tenantId: 'tenant-1', name: '异常应用', queuePriority: 'urgent', }), ).rejects.toThrow('queuePriority must be normal or priority'); expect(prisma.smsApplication.create).not.toHaveBeenCalled(); }); it('rejects unavailable enterprise application interface types', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.createApplication({ tenantId: 'tenant-1', name: 'HTTP应用', interfaceType: 'http', }), ).rejects.toThrow('interfaceType only supports cmpp20'); expect(prisma.smsApplication.create).not.toHaveBeenCalled(); }); it('rejects duplicate or invalid CMPP application accounts', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.createApplication({ tenantId: 'tenant-1', name: '异常应用', cmppAccount: 'abc', }), ).rejects.toThrow('cmppAccount must be a 6-digit number'); await expect( service.createApplication({ tenantId: 'tenant-1', name: '重复应用', cmppAccount: '100001', }), ).rejects.toThrow('cmppAccount already exists'); }); it('updates enterprise application profile and allowlist through a transaction', async () => { const prisma = createPrismaMock(); const tx = { smsApplication: { update: jest.fn().mockResolvedValue({ id: 'app-1', name: '新应用', ipAllowlist: [{ ipCidr: '10.0.0.1/32' }] }), }, smsApplicationIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), }, channelRouteRule: prisma.channelRouteRule, }; prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 })).rejects.toThrow( '客户单价最多支持人民币小数点后 4 位', ); await expect( service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'], }), ).resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' })); expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } }); expect(tx.smsApplication.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'app-1' }, data: expect.objectContaining({ name: '新应用', cmppEnterpriseCode: '100001', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, }), }), ); }); it('derives both downstream delivery modes when CMPP is enabled alongside HTTP', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', interfaceEnabled: false, httpConfig: { enabled: true }, }); const tx = { smsApplication: { update: jest.fn().mockResolvedValue({ id: 'app-1', interfaceEnabled: true }), }, smsApplicationHttpConfig: { update: jest.fn().mockResolvedValue({}), }, }; prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); await service.updateApplication('app-1', { interfaceEnabled: true }); expect(tx.smsApplicationHttpConfig.update).toHaveBeenCalledWith({ where: { applicationId: 'app-1' }, data: { receiptDeliveryMode: 'both', uplinkDeliveryMode: 'both', }, }); }); it('replaces application carrier channel-group routes with carrier validation', async () => { const prisma = createPrismaMock(); const tx = { channelRouteRule: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), createMany: jest.fn().mockResolvedValue({ count: 2 }), findMany: jest.fn().mockResolvedValue([ { id: 'rule-1', carrier: 'mobile', groupId: 'group-mobile' }, { id: 'rule-2', carrier: 'unicom', groupId: 'group-unicom' }, ]), }, }; prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); await expect( service.replaceApplicationRouteRules('app-1', { routes: [ { carrier: 'mobile', groupId: 'group-mobile' }, { carrier: 'unicom', groupId: 'group-unicom' }, ], }), ).resolves.toEqual([ expect.objectContaining({ carrier: 'mobile' }), expect.objectContaining({ carrier: 'unicom' }), ]); expect(tx.channelRouteRule.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1', channelId: null, province: null }, }); expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({ data: [ expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10, }), expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20, }), ], }); }); it('rejects application routes when channel-group carrier does not match', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.replaceApplicationRouteRules('app-1', { routes: [{ carrier: 'telecom', groupId: 'group-mobile' }], }), ).rejects.toThrow('channel group carrier must match route carrier'); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it('hides CMPP params when the application belongs to another tenant', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found'); }); it('forbids client CMPP parameter access when the interface is not enabled', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', interfaceEnabled: false, tenant: { id: 'tenant-1', name: '租户A' }, }); const service = new SmsConfigService(prisma as never); await expect(service.getApplicationCmppParams('app-1', 'tenant-1')).rejects.toThrow('该企业应用未开通 CMPP 接口'); }); it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await service.recordDownstreamConnectionEvent({ account: '100001', connectionId: 'gateway-1-1', status: 'connected', remoteIp: '127.0.0.1', protocol: 'cmpp30', observedAt: '2026-07-11T11:00:00.000Z', }); expect(prisma.cmppDownstreamConnection.upsert).toHaveBeenCalledWith({ where: { connectionId: 'gateway-1-1' }, create: expect.objectContaining({ applicationId: 'app-1', tenantId: 'tenant-1', account: '100001', enterpriseCode: 'APP-EC', connectionId: 'gateway-1-1', status: 'connected', remoteIp: '127.0.0.1', }), update: expect.objectContaining({ applicationId: 'app-1', status: 'connected', }), }); expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({ where: { status: 'connected', OR: [ { lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } }, { lastHeartbeatAt: null, connectedAt: { lt: new Date('2026-07-11T10:58:30.000Z') }, }, ], }, }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'cmpp_downstream_connection.connected', resource: 'cmpp_downstream_connection', resourceId: 'gateway-1-1', }), }); }); it('prunes expired downstream connections whose heartbeat was never recorded', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); const now = new Date('2026-07-21T04:00:00.000Z'); await service.markTimedOutDownstreamConnections(now); expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({ where: { status: 'connected', OR: [ { lastHeartbeatAt: { lt: new Date('2026-07-21T03:58:30.000Z') } }, { lastHeartbeatAt: null, connectedAt: { lt: new Date('2026-07-21T03:58:30.000Z') }, }, ], }, }); }); it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1, interfaceEnabled: true, status: 'active', ipAllowlist: [{ ipCidr: '10.0.0.0/24' }], }); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-2', connectedAt: new Date() }); const service = new SmsConfigService(prisma as never); await expect( service.recordDownstreamConnectionEvent({ account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1', }), ).rejects.toThrow('CMPP source IP is not in application allowlist'); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1, interfaceEnabled: true, status: 'active', ipAllowlist: [], }); prisma.cmppDownstreamConnection.findMany.mockResolvedValue([ { connectionId: 'gateway-1-1' }, { connectionId: 'gateway-1-2' }, ]); await expect( service.recordDownstreamConnectionEvent({ account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1', }), ).rejects.toThrow('CMPP connection limit exceeded (1)'); }); it.each([ ['heartbeat', 'lastHeartbeatAt'], ['submit', 'lastSubmitAt'], ['deliver', 'lastDeliverAt'], ] as const)( 'updates downstream %s state without appending high-frequency operation logs', async (status, timestampField) => { const prisma = createPrismaMock(); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1', connectedAt: new Date('2026-07-11T11:00:00.000Z'), lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'), lastSubmitAt: null, lastDeliverAt: null, lastError: null, }); const service = new SmsConfigService(prisma as never); await service.recordDownstreamConnectionEvent({ account: '100001', connectionId: 'gateway-1-1', status, observedAt: '2026-07-11T11:00:30.000Z', }); expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'downstream-1' }, data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }), }), ); expect(prisma.operationLog.create).not.toHaveBeenCalled(); }, ); it('removes a disconnected downstream session instead of retaining connection history', async () => { const prisma = createPrismaMock(); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1' }); const service = new SmsConfigService(prisma as never); await expect( service.recordDownstreamConnectionEvent({ account: '100001', connectionId: 'gateway-1-1', status: 'disconnected', errorMessage: 'client closed', }), ).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true })); expect(prisma.cmppDownstreamConnection.delete).toHaveBeenCalledWith({ where: { id: 'downstream-1' } }); expect(prisma.cmppDownstreamConnection.update).not.toHaveBeenCalled(); }); it('lists enterprise signatures with keyword filters and real relations', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网', }), ).resolves.toEqual([ expect.objectContaining({ id: 'sig-1', tenant: expect.objectContaining({ name: '租户A' }), application: expect.objectContaining({ name: '应用A' }), }), ]); expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' }, tenant: { name: { contains: '租户' } }, application: { name: { contains: '应用' } }, name: { contains: '签名' }, drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }), }), select: expect.objectContaining({ materials: true, tenant: { select: { id: true, name: true, code: true, status: true } }, application: { select: { id: true, tenantId: true, name: true, status: true } }, drainageItems: expect.objectContaining({ where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' }, }), reportTasks: expect.objectContaining({ select: expect.any(Object) }), reportBatchItems: expect.any(Object), }), orderBy: { createdAt: 'desc' }, }), ); }); it('merges report fields from every channel in the application channel groups', async () => { const prisma = createPrismaMock(); prisma.channelRouteRule.findMany.mockResolvedValue([ { id: 'route-1', priority: 10, group: { id: 'group-1', name: '默认通道组', items: [ { channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [ { status: 'active', required: false, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active', }, }, ], }, }, { channel: { id: 'channel-2', code: 'CH-2', name: '通道二', reportFields: [ { status: 'active', required: true, reportType: 'both', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active', }, }, ], }, }, ], }, }, ] as never); const service = new SmsConfigService(prisma as never); await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([ expect.objectContaining({ id: 'field-1', code: 'license', required: true, reportTypes: ['signature', 'both'], channels: [ expect.objectContaining({ id: 'channel-1', groupId: 'group-1' }), expect.objectContaining({ id: 'channel-2', groupId: 'group-1' }), ], }), ]); }); it('merges common report fields into every target channel requirement', async () => { const prisma = createPrismaMock(); prisma.commonReportField.findMany.mockResolvedValue([ { id: 'common-1', reportType: 'signature', required: true, status: 'active', drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active', }, }, ]); prisma.channelRouteRule.findMany.mockResolvedValue([ { id: 'route-1', priority: 10, group: { id: 'group-1', name: '默认通道组', items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }], }, }, ] as never); const service = new SmsConfigService(prisma as never); await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([ expect.objectContaining({ id: 'field-common', required: true, reportTypes: ['signature'], commonReportTypes: ['signature'], channels: [expect.objectContaining({ id: 'channel-1', source: 'common', required: true })], }), ]); }); it('removes channel sources from client report-field responses', async () => { const prisma = createPrismaMock(); prisma.channelRouteRule.findMany.mockResolvedValue([ { id: 'route-1', priority: 10, group: { id: 'group-1', name: '内部通道组', items: [ { channel: { id: 'channel-secret', code: 'SECRET-CH', name: '内部通道', reportFields: [ { status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active', }, }, ], }, }, ], }, }, ] as never); const service = new SmsConfigService(prisma as never); const fields = await service.getClientApplicationReportFields('app-1', 'signature'); expect(fields).toEqual([expect.objectContaining({ id: 'field-1', code: 'license', required: true })]); expect(JSON.stringify(fields)).not.toContain('channel-secret'); expect(JSON.stringify(fields)).not.toContain('内部通道'); expect(fields[0]).not.toHaveProperty('channels'); }); it('returns client signatures without report tasks, channels, or internal requirement snapshots', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findMany.mockResolvedValue([ { id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '通知', auditStatus: 'rejected', rejectReason: '请补充资料', drainageInfo: { signatureReportValues: { license: 'file-1' }, reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }], }, createdAt: new Date('2026-07-16T01:00:00Z'), updatedAt: new Date('2026-07-16T02:00:00Z'), application: { id: 'app-1', name: '应用A', status: 'active' }, materials: [ { id: 'material-1', fileObjectId: 'file-1', materialType: 'license', title: '营业执照', description: null, createdAt: new Date(), }, ], drainageItems: [ { id: 'drainage-1', siteName: '官网', url: 'https://example.com', remark: null, reportValues: { owner: '企业A' }, auditStatus: 'pending', rejectReason: null, submittedAt: new Date(), reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }, ], _count: { reportMaterials: 2 }, reportTasks: [{ channelId: 'channel-secret' }], }, ] as never); const service = new SmsConfigService(prisma as never); const result = await service.listClientSignatures('tenant-1'); const serialized = JSON.stringify(result); expect(result[0]).toEqual( expect.objectContaining({ id: 'sig-1', submittedMaterialCount: 3, reportValues: { license: 'file-1' }, drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] }, }), ); expect(serialized).not.toContain('channel-secret'); expect(serialized).not.toContain('内部通道'); expect(result[0]).not.toHaveProperty('reportTasks'); expect(result[0]).toHaveProperty('reportStatus'); }); it('returns real client signature workspace counts from database grouping', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findMany.mockResolvedValue([]); prisma.smsSignature.count.mockResolvedValue(8); prisma.smsSignature.groupBy.mockResolvedValue([ { auditStatus: 'pending', _count: { _all: 2 } }, { auditStatus: 'approved', _count: { _all: 5 } }, { auditStatus: 'rejected', _count: { _all: 1 } }, ] as never); const service = new SmsConfigService(prisma as never); await expect(service.getClientSignatureWorkspace('tenant-1')).resolves.toEqual({ items: [], summary: { total: 8, pending: 2, approved: 5, rejected: 1, draft: 0 }, total: 8, page: 1, pageSize: 10, }); expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith( expect.objectContaining({ where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } }, }), ); }); it('selects client drainage information without internal tasks or channels', async () => { const prisma = createPrismaMock(); prisma.smsDrainageInfo.findMany.mockResolvedValue([{ id: 'drainage-1', siteName: '官网' }] as never); const service = new SmsConfigService(prisma as never); await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([ { id: 'drainage-1', siteName: '官网' }, ]); const query = prisma.smsDrainageInfo.findMany.mock.calls[0][0]; expect(query.where).toEqual({ id: undefined, tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }); expect(query.select).not.toHaveProperty('reportTasks'); expect(JSON.stringify(query.select)).not.toContain('channel'); }); it('requires common signature fields even when a signature is not bound to an application', async () => { const prisma = createPrismaMock(); prisma.commonReportField.findMany.mockResolvedValue([ { id: 'common-1', reportType: 'signature', required: true, status: 'active', drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active', }, }, ]); const service = new SmsConfigService(prisma as never); await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' })).rejects.toThrow( '缺少必填签名报备资料:统一社会信用代码', ); expect(prisma.smsSignature.create).not.toHaveBeenCalled(); }); it('requires common drainage fields when adding drainage info without an application', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null, auditStatus: 'approved', }); prisma.commonReportField.findMany.mockResolvedValue([ { id: 'common-2', reportType: 'drainage', required: true, status: 'active', drainageField: { id: 'field-site', code: 'siteOwner', name: '网站主体', fieldType: 'string', description: null, status: 'active', }, }, ]); const service = new SmsConfigService(prisma as never); await expect( service.createDrainageInfo('sig-1', { url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'), ).rejects.toThrow('引流信息缺少必填报备资料:网站主体'); expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled(); }); it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft', }); prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }), ); prisma.channelRouteRule.findMany.mockResolvedValue([ { id: 'route-1', priority: 10, group: { id: 'group-1', name: '默认通道组', items: [ { channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [ { status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active', }, }, { status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'text', description: null, status: 'active', }, }, ], }, }, ], }, }, ] as never); const service = new SmsConfigService(prisma as never); await service.updateSignature('sig-1', { applicationId: 'app-1', drainageInfo: { signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } }, links: [{ id: 'legacy-drain-1', reportValues: { site_owner: '企业A' } }], }, }); expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith( expect.objectContaining({ create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1', }), }), ); expect(prisma.drainageReportMaterial.create).not.toHaveBeenCalled(); expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); it('creates client drainage info as pending without generating channel report tasks', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved', }); prisma.channelRouteRule.findMany.mockResolvedValue([]); prisma.$transaction.mockImplementation((callback) => callback(prisma)); const service = new SmsConfigService(prisma as never); await expect( service.createDrainageInfo('sig-1', { url: '13800138000', reportValues: {} }, {}, 'tenant-1'), ).resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ siteName: '13800138000', url: '13800138000' }), }), ); expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }), }); expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); it('never lets client status filters bypass deleted signature exclusion and keeps template history filtered', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findMany.mockResolvedValue([]); prisma.smsSignature.groupBy.mockResolvedValue([] as never); prisma.smsTemplate.findMany.mockResolvedValue([]); const service = new SmsConfigService(prisma as never); for (const status of ['deleted', 'all', 'approved']) { await service.getClientSignatureWorkspace('tenant-1', { status }); const filter = { notIn: ['deleted', 'disabled'], ...(status !== 'all' ? { equals: status } : {}) }; expect(prisma.smsSignature.findMany).toHaveBeenLastCalledWith( expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: filter }) }), ); expect(prisma.smsSignature.count).toHaveBeenLastCalledWith( expect.objectContaining({ where: expect.objectContaining({ auditStatus: filter }) }), ); } await service.listClientTemplates('tenant-1', true); expect(prisma.smsTemplate.findMany).toHaveBeenLastCalledWith( expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }), }), ); await service.listTemplatesPage({ tenantId: 'tenant-1', status: 'all', page: 1, pageSize: 10 }); expect(prisma.smsTemplate.count).toHaveBeenLastCalledWith( expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' } }) }), ); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-deleted', tenantId: 'tenant-1', auditStatus: 'deleted', } as never); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-deleted', tenantId: 'tenant-1', auditStatus: 'deleted', } as never); await expect(service.submitSignature('sig-deleted', 'tenant-1')).rejects.toThrow('not found'); await expect(service.submitTemplate('tpl-deleted', 'tenant-1')).rejects.toThrow('not found'); }); it('uses exactly the same editable common fields in client and admin signature forms', async () => { const prisma = createPrismaMock(); const field = { id: 'field-common', code: 'license', name: '主体证明', fieldType: 'file', description: '最新配置', status: 'active', }; prisma.commonReportField.findMany.mockResolvedValue([ { id: 'common-1', reportType: 'signature', required: true, drainageField: field }, ]); const service = new SmsConfigService(prisma as never); for (const applicationId of [undefined, 'app-1']) { const admin = await service.getApplicationReportFields(applicationId, 'signature'); const client = await service.getClientApplicationReportFields(applicationId, 'signature'); expect(client).toEqual( admin.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => !['channels', 'commonReportTypes'].includes(key))), ), ); expect(client[0]).toMatchObject({ code: 'license', name: '主体证明', fieldType: 'file', required: true, description: '最新配置', }); } prisma.commonReportField.findMany.mockResolvedValue([ { id: 'common-1', reportType: 'signature', required: false, drainageField: field }, ]); expect((await service.getClientApplicationReportFields(undefined, 'signature'))[0].required).toBe(false); }); it('keeps both common field purposes in material snapshots and ignores deleted channel requirements', async () => { const prisma = createPrismaMock(); const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' }; prisma.commonReportField.findMany.mockResolvedValue([ { reportType: 'signature', required: true, drainageField: field }, { reportType: 'drainage', required: false, drainageField: field }, ]); prisma.channelRouteRule.findMany.mockResolvedValue([ { group: { id: 'group-1', items: [ { channel: { id: 'deleted-channel', status: 'deleted', reportFields: [ { status: 'active', reportType: 'signature', required: true, drainageField: { ...field, id: 'old', code: 'old' }, }, ], }, }, ], }, }, ] as never); const result = await new SmsConfigService(prisma as never).getApplicationReportFields('app-1'); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ required: true, reportTypes: ['signature', 'drainage'], commonReportTypes: ['signature', 'drainage'], channels: [], }); }); it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved', }); prisma.channelRouteRule.findMany.mockResolvedValue([]); prisma.$transaction.mockImplementation((callback) => callback(prisma)); const service = new SmsConfigService(prisma as never); await expect( service.createDrainageInfo('sig-1', { url: 'example.com/path', reportValues: {} }, {}, 'tenant-1'), ).resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ siteName: 'example.com/path', url: 'example.com/path' }), }), ); }); it('resets an approved signature to pending when key content is changed', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】', purpose: '通知', drainageInfo: {}, auditStatus: 'approved', }); const service = new SmsConfigService(prisma as never); await service.updateSignature('sig-1', { name: '【新签名】' }); expect(prisma.smsSignature.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }), }), ); }); it('keeps an operator-edited signature approved and records the operator audit', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】', purpose: '通知', drainageInfo: {}, auditStatus: 'approved', }); const service = new SmsConfigService(prisma as never); const result = await service.updateSignature('sig-1', { name: '【新签名】' }, undefined, { initialAuditStatus: 'approved', reviewerId: 'operator-1', }); expect(prisma.smsSignature.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ auditStatus: 'approved', rejectReason: null, materialVersion: { increment: 1 }, pendingReport: true, }), }), ); expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_update_approved', reviewerId: 'operator-1', statusBefore: 'approved', statusAfter: 'approved', }), }); expect(result).toEqual( expect.objectContaining({ auditStatus: 'approved', reportMaterialChanged: true, reportPoolAvailableAfter: 'immediate', }), ); }); it('resets an approved template to pending when key content is changed', async () => { const prisma = createPrismaMock(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '模板A', content: '【签名A】验证码${code}', category: '验证码', auditStatus: 'approved', }); const tx = { templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) }, smsTemplate: { update: jest.fn().mockResolvedValue({ id: 'tpl-1', auditStatus: 'pending' }) }, }; prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); await service.updateTemplate('tpl-1', { content: '【签名A】您的验证码为${code}' }); expect(tx.smsTemplate.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ auditStatus: 'pending' }), }), ); }); it('creates real drainage materials and channel tasks after operations approval', async () => { const prisma = createPrismaMock(); const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending', }; const approvedItem = { ...pendingItem, auditStatus: 'approved', signature: { id: 'sig-1', applicationId: 'app-1' }, }; prisma.smsDrainageInfo.findUnique.mockResolvedValueOnce(pendingItem).mockResolvedValueOnce(approvedItem); prisma.smsDrainageInfo.update.mockResolvedValue({ ...approvedItem, tenant: {}, application: {} }); prisma.channelRouteRule.findMany.mockResolvedValue([ { id: 'route-1', priority: 10, group: { id: 'group-1', name: '默认通道组', items: [ { channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [ { status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'string', description: null, status: 'active', }, }, ], }, }, ], }, }, ] as never); prisma.$transaction.mockImplementation((callback) => callback(prisma)); const service = new SmsConfigService(prisma as never); await service.approveDrainageInfo('drainage-1', {}); expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ data: expect.objectContaining({ drainageItemId: 'drainage-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A', }), }); expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }), }); expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }), }); }); it('creates admin signatures with an approved initial audit status', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await service.createSignature( { tenantId: 'tenant-1', name: '【运营新建签名】' }, { initialAuditStatus: 'approved' }, ); expect(prisma.smsSignature.create).toHaveBeenCalledWith({ data: expect.objectContaining({ auditStatus: 'approved', name: '【运营新建签名】' }), }); expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }), }); }); it.each(['未带括号', '[英文括号]', '【【重复括号】】'])( 'rejects a signature name without exactly one complete Chinese black bracket pair: %s', async (name) => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect(service.createSignature({ tenantId: 'tenant-1', name })).rejects.toThrow( '短信签名必须包含完整中文黑括号,例如:【某某科技】', ); expect(prisma.smsSignature.create).not.toHaveBeenCalled(); }, ); it('rejects editing a signature to a name without complete Chinese black brackets', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect(service.updateSignature('sig-1', { name: '编辑无括号' })).rejects.toThrow( '短信签名必须包含完整中文黑括号,例如:【某某科技】', ); expect(prisma.smsSignature.update).not.toHaveBeenCalled(); }); it('accepts concurrent state callbacks for the same newly connected Gateway session', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1, interfaceEnabled: true, status: 'active', ipAllowlist: [], }); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue(null); prisma.cmppDownstreamConnection.findMany.mockResolvedValue([{ connectionId: 'gateway-current' }]); const service = new SmsConfigService(prisma as never); await expect( service.recordDownstreamConnectionEvent({ account: '100001', connectionId: 'gateway-current', status: 'submit', remoteIp: '127.0.0.1', }), ).resolves.toEqual(expect.objectContaining({ connectionId: 'gateway-current' })); expect(prisma.cmppDownstreamConnection.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { connectionId: 'gateway-current' }, }), ); }); it('applies the audit submission range to signatures, templates and drainage records', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); const query = { submittedAtFrom: '2026-08-01', submittedAtTo: '2026-08-03' }; const expectedRange = { gte: new Date('2026-08-01T00:00:00+08:00'), lte: new Date('2026-08-03T23:59:59.999+08:00'), }; await service.listSignatures(query); await service.listTemplates(query); await service.listDrainageInfos(query); expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) }), ); expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) }), ); expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) }), ); }); it('returns a signature page summary without edit materials or report target arrays', async () => { const prisma = createPrismaMock(); prisma.smsSignature.count.mockResolvedValue(1); const service = new SmsConfigService(prisma as never); const result = await service.listSignaturesPage({ page: 1, pageSize: 10 }); expect(result.total).toBe(1); expect(result.pendingReportMaterialTotal).toBe(0); expect(result.pendingReportDetailTotal).toBe(0); expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' })); expect(result.items[0]).not.toHaveProperty('materials'); expect(result.items[0]).not.toHaveProperty('reportTasks'); expect(result.items[0]).not.toHaveProperty('reportTargets'); expect(result.items[0]).not.toHaveProperty('drainageReportTargets'); }); it.each([ ['abandoned', 1], ['pending', 2], ['reporting', 2], ['approved', 2], ['failed', 2], ['rejected', 2], ])('keeps the pending detail total aligned after a persisted %s task status', async (taskStatus, expectedTotal) => { const prisma = createPrismaMock(); prisma.smsSignature.findMany.mockImplementation(({ where }) => { if (where?.AND) { return Promise.resolve([ { id: 'sig-pending', applicationId: 'app-1', materialVersion: 2, application: { status: 'active' }, reportTasks: [ { channelId: 'channel-1', carrier: 'mobile', status: taskStatus, approvalScope: 'carrier_specific' }, ], reportBatchItems: [], }, ]); } return Promise.resolve([]); }); prisma.channelRouteRule.findMany.mockResolvedValue([ { applicationId: 'app-1', group: { status: 'active', items: [ { channel: { id: 'channel-1', status: 'active', carrier: 'mobile', carriers: ['mobile', 'unicom'], reportFields: [], }, }, ], }, }, ] as never); const service = new SmsConfigService(prisma as never); const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 }); expect(result.pendingReportMaterialTotal).toBe(1); expect(result.pendingReportDetailTotal).toBe(expectedTotal); expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { AND: [ expect.objectContaining({ name: { contains: '测试' } }), { auditStatus: 'approved', pendingReport: true }, ], }, }), ); }); it.each([ '【带 空格】', ' 【外部空格】', '【不换\n行】', '【零宽\u200B字符】', '【不换行空格\u00A0】', '【字节顺序标记\uFEFF】', '【变体选择符\uFE0F】', ])('rejects spaces, controls, and invisible characters in signature names: %s', async (name) => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect(service.createSignature({ tenantId: 'tenant-1', name })).rejects.toThrow( '短信签名不能包含空格、换行或不可见字符', ); expect(prisma.smsSignature.create).not.toHaveBeenCalled(); }); it('updates enterprise signature drainage info through the admin API path', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.updateSignature('sig-1', { name: '【签名B】', auditStatus: 'approved', drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' }, links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }], }, }), ).resolves.toEqual( expect.objectContaining({ id: 'sig-1', name: '【签名B】', auditStatus: 'approved', }), ); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: expect.objectContaining({ name: '【签名B】', auditStatus: 'approved', drainageInfo: expect.objectContaining({ carrierStatus: expect.objectContaining({ mobile: 'approved' }), }), }), include: { materials: true, tenant: true, application: true }, }); }); it('lists enterprise templates with real relations and excludes deleted by default', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.listTemplates({ enterpriseKeyword: '租户', applicationKeyword: '应用', nameKeyword: '模板', contentKeyword: '验证码', }), ).resolves.toEqual([ expect.objectContaining({ id: 'tpl-1', tenant: expect.objectContaining({ name: '租户A' }), application: expect.objectContaining({ name: '应用A' }), signature: expect.objectContaining({ name: '签名A' }), }), ]); expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' }, tenant: { name: { contains: '租户' } }, application: { name: { contains: '应用' } }, name: { contains: '模板' }, content: { contains: '验证码' }, }), include: { variables: true, application: true, tenant: true, signature: true }, }), ); }); it('returns only approved templates from the client send-candidate view by default', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await service.listClientTemplates('tenant-1'); expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: 'approved' }), }), ); }); it('creates admin enterprise templates as approved when requested', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.createTemplate( { tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '运营添加模板', content: '【签名A】您的验证码为${code}', variables: [{ name: 'code', example: '123456', required: true }], }, { initialAuditStatus: 'approved' }, ), ).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' })); expect(prisma.smsTemplate.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '运营添加模板', content: '【签名A】您的验证码为${code}', auditStatus: 'approved', variables: { create: [{ name: 'code', example: '123456', required: true }], }, }), include: { variables: true, application: true, tenant: true, signature: true }, }), ); }); it('updates enterprise templates and rebuilds template variables', async () => { const prisma = createPrismaMock(); const tx = { templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), }, smsTemplate: { update: jest.fn().mockResolvedValue({ id: 'tpl-1', name: '模板B', variables: [{ name: 'code' }] }), }, }; prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); await expect( service.updateTemplate('tpl-1', { applicationId: 'app-1', signatureId: 'sig-1', name: '模板B', content: '【签名A】验证码${code}', variables: [{ name: 'code', example: '123456', required: true }], }), ).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' })); expect(tx.templateVariable.deleteMany).toHaveBeenCalledWith({ where: { templateId: 'tpl-1' } }); expect(tx.smsTemplate.update).toHaveBeenCalledWith({ where: { id: 'tpl-1' }, data: expect.objectContaining({ applicationId: 'app-1', signatureId: 'sig-1', name: '模板B', content: '【签名A】验证码${code}', variables: { create: [{ name: 'code', example: '123456', required: true }], }, }), include: { variables: true, application: true, tenant: true, signature: true }, }); }); it('requires the selected signature at the start of template content', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.createTemplate({ tenantId: 'tenant-1', applicationId: 'app-1', name: '缺少签名模板', content: '您的验证码为${code}', }), ).rejects.toThrow('短信模板必须选择短信签名'); await expect( service.createTemplate({ tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '签名不匹配模板', content: '【其他签名】您的验证码为${code}', }), ).rejects.toThrow('模板内容必须以所选短信签名 【签名A】 开头'); expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); }); it.each([ ['空变量', '【签名A】验证码${}'], ['中文变量', '【签名A】验证码${中文}'], ['未闭合变量', '【签名A】验证码${code'], ['重复变量', '【签名A】${code}-${code}'], ['超长变量', `【签名A】\${${'a'.repeat(33)}}`], ])('rejects %s before persisting the template', async (_label, content) => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect( service.createTemplate({ tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '非法模板', content, }), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); }); });