import { SmsConfigService } from './sms-config.service'; function createPrismaMock() { return { smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A', status: 'active', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }], }]), findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', name: '应用A', status: 'active', secretHash: 'secret-hash', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), 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' }, ]), }, smsSignature: { 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: [], }]), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, smsTemplate: { 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', 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([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]), findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })), }, smsChannel: { 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(), }, $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 })).resolves.toEqual([ expect.objectContaining({ id: 'app-1', cmppStatus: 'connected', sentToday: 2, deliveryRate: 50, cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })], }), ]); }); it('returns CMPP params from persisted application and channel config', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({ applicationId: 'app-1', tenantName: '租户A', gatewayHost: '127.0.0.1', gatewayPort: 17890, maxConnections: 2, })); }); 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', { name: '新应用', customerUnitPrice: 300, 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: '新应用', customerUnitPrice: 300, ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, }), })); }); 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('disconnects application CMPP connections and writes operation logs', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' }); expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } }, data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'cmpp_connection.disconnected', resource: 'cmpp_connection', resourceId: 'channel-1:conn-a', }), }); }); 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({ keyword: '签名A' })).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' }, OR: expect.any(Array), }), include: { materials: true, tenant: true, application: true }, })); }); 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({ keyword: '模板A' })).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' }, OR: expect.any(Array), }), 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: '验证码${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: '验证码${code}', variables: { create: [{ name: 'code', example: '123456', required: true }], }, }), include: { variables: true, application: true, tenant: true, signature: true }, }); }); });