import { ChannelsService } from './channels.service'; const mockJobRemove = jest.fn().mockResolvedValue(undefined); const mockQueueAdd = jest.fn().mockImplementation(async () => ({ id: 'job-1', remove: mockJobRemove })); const mockQueueClose = jest.fn().mockResolvedValue(undefined); const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, text: jest.fn().mockResolvedValue(''), }); const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0'); const mockRedisSet = jest.fn().mockResolvedValue('OK'); const mockRedisEval = jest.fn().mockResolvedValue(1); const mockRedisDisconnect = jest.fn(); jest.mock('bullmq', () => ({ Queue: jest.fn().mockImplementation(() => ({ add: mockQueueAdd, close: mockQueueClose, })), })); jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({ xadd: mockRedisXadd, set: mockRedisSet, eval: mockRedisEval, disconnect: mockRedisDisconnect, })), ); function createPrismaMock() { const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', reportType: 'signature', status: 'pending', }; const channel = { id: 'channel-1', code: 'CMPP-A', name: '主通道', carrier: 'mobile', carriers: ['mobile'], protocol: 'CMPP', gatewayHost: '127.0.0.1', gatewayPort: 17890, enterpriseCode: 'EC', account: 'sp', passwordCipher: 'secret', srcId: '10690000', cmppVersion: '2.0', rateLimitPerSecond: 100, unitPrice: 3, status: 'active', config: { serviceId: 'SMS' }, sendRegion: '山东', connectionStates: [{ id: 'state-1', connectionId: 'conn-a', status: 'connected', currentConnections: 1 }], reportFields: [ { code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active', }, ], }; return { $queryRaw: jest.fn().mockResolvedValue([]), $transaction: jest.fn((callback) => callback({ smsChannel: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), }, smsChannelGroup: { update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), findUnique: jest .fn() .mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }), }, smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn(), }, signatureReportMaterial: { findMany: jest .fn() .mockResolvedValue([ { signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }, ]), createMany: jest.fn(), }, operationLog: { create: jest.fn(), }, }), ), smsChannel: { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), findUnique: jest.fn().mockResolvedValue(channel), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })), }, channelHealthMetric: { findMany: jest.fn() }, smsChannelGroup: { findMany: jest.fn(), findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [], }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }), }, smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })), }, channelRouteRule: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })), }, channelReportField: { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), }, drainageField: { findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件', }), findMany: jest.fn().mockResolvedValue([ { id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件', }, ]), }, drainageDetectionRule: { findMany: jest.fn().mockResolvedValue([]), }, signatureReportMaterial: { findMany: jest .fn() .mockResolvedValue([ { signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }, ]), createMany: jest.fn(), upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })), }, channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]), create: jest.fn().mockResolvedValue(reportTask), findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(reportTask), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })), }, smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }), }, channelSignatureReportRecord: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'record-1', ...data })), findMany: jest.fn(), }, reportExportFile: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'export-1', ...data })), }, reportReceiptImport: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })), }, smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null }), update: jest.fn(), }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), findMany: jest.fn().mockResolvedValue([]), }, tenant: { findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }), }, smsBatchTask: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'batch-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'batch-1', ...data })), }, cmppSubmitSession: { upsert: jest.fn().mockResolvedValue({ id: 'session-1', channelId: 'channel-1', sessionNo: 'OPEN-channel-1' }), }, smsMessageRecord: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'message-record-1', ...data })), }, smsSubmitRecord: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })), count: jest.fn().mockResolvedValue(0), }, cmppConnectionState: { findMany: jest.fn(), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, operationLog: { create: jest.fn(), findMany: jest.fn().mockResolvedValue([ { id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date(), }, ]), }, }; } describe('ChannelsService', () => { it('separates three-network totals without assigning unknown carrier traffic', async () => { const prisma = createPrismaMock(); prisma.channelSignatureReportTask.findMany.mockResolvedValue( ['mobile', 'unicom', 'telecom'].map((carrier) => ({ id: carrier, channelId: 'c', signatureId: 's', reportType: 'signature', carrier, })), ); prisma.$queryRaw.mockResolvedValue( ['mobile', 'unicom', 'telecom', null].map((carrier, i) => ({ carrier, channelId: 'c', signatureId: 's', drainageInfoId: null, total: i + 1, acceptedCount: i + 1, successCount: i + 1, failureCount: 0, unknownCount: 0, submitFailureCount: 0, lastSuccessfulSentAt: null, })), ); const rows = await new ChannelsService(prisma as never).listReportTasks(); expect(rows).toEqual( [1, 2, 3].map((total) => expect.objectContaining({ deliveryStats: expect.objectContaining({ total }) })), ); expect(prisma.$queryRaw.mock.calls[0][0].sql).toContain( 'GROUP BY channel_id, signature_id, drainage_info_id, carrier', ); }); it('sorts all filtered channels by today submit count before pagination', async () => { const prisma = createPrismaMock(); const candidates = [ { id: 'channel-low', name: '乙通道' }, { id: 'channel-high', name: '甲通道' }, { id: 'channel-zero', name: '丙通道' }, ]; const fullChannels = candidates.map((channel) => ({ ...channel, connectionStates: [] })); prisma.smsChannel.findMany .mockResolvedValueOnce(candidates) .mockResolvedValueOnce([fullChannels[0], fullChannels[1]]); prisma.$queryRaw.mockResolvedValue([ { channelId: 'channel-low', total: 3 }, { channelId: 'channel-high', total: 12 }, ]); const service = new ChannelsService(prisma as never); const result = await service.listChannelsPage({ page: 1, pageSize: 2 }); expect(result.items.map((channel) => channel.id)).toEqual(['channel-high', 'channel-low']); expect(result.total).toBe(3); expect(prisma.smsChannel.findMany).toHaveBeenNthCalledWith(2, { where: { id: { in: ['channel-high', 'channel-low'] } }, include: { connectionStates: true }, }); }); it('uses channel name and id as a stable tie breaker for zero-submit channels', async () => { const prisma = createPrismaMock(); const candidates = [ { id: 'channel-b', name: 'A通道' }, { id: 'channel-a', name: 'A通道' }, { id: 'channel-c', name: 'B通道' }, ]; prisma.smsChannel.findMany.mockResolvedValueOnce(candidates).mockResolvedValueOnce(candidates); prisma.$queryRaw.mockResolvedValue([]); const service = new ChannelsService(prisma as never); const result = await service.listChannelsPage({ page: 1, pageSize: 10 }); expect(result.items.map((channel) => channel.id)).toEqual(['channel-a', 'channel-b', 'channel-c']); }); it('creates channel report requirements only from the report field library', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.createReportField({ channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'signature', code: 'ignored', name: 'ignored', fieldType: 'string', }); expect(prisma.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'signature', code: 'license', name: '营业执照', fieldType: 'file', required: true, }), }); }); it('replaces one report type while preserving legacy both fields for the opposite type', async () => { const prisma = createPrismaMock(); const legacyBoth = { id: 'legacy-1', channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'both', code: 'license', name: '营业执照', exportName: '旧表头', fieldType: 'file', required: true, description: null, sortOrder: 10, columnWidth: 18, imageWidth: 120, imageHeight: 80, defaultValue: null, transform: null, status: 'active', createdAt: new Date(), updatedAt: new Date(), }; const tx = { $executeRaw: jest.fn().mockResolvedValue(1), channelReportField: { findMany: jest .fn() .mockResolvedValueOnce([legacyBoth]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: 'signature-field' }]), deleteMany: jest.fn(), create: jest .fn() .mockImplementation(({ data }) => Promise.resolve({ id: `created-${data.reportType}`, ...data })), }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); await service.replaceReportFields('channel-1', 'signature', { fields: [{ drainageFieldId: 'library-1', exportName: '新签名表头', required: true }], }); expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1', reportType: { in: ['signature', 'both'] } }, }); expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'drainage', exportName: '旧表头', }), }); expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'signature', exportName: '新签名表头', }), }); }); it('lists report tasks for one real channel', async () => { const prisma = createPrismaMock(); prisma.channelSignatureReportTask.findMany.mockResolvedValue([]); const service = new ChannelsService(prisma as never); await service.listReportTasks(undefined, undefined, 'channel-1'); expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({ where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined, signature: { auditStatus: { not: 'deleted' } }, }, include: { signature: { include: { tenant: true, application: true } }, channel: true, drainageInfo: true, exportItems: { include: { exportFile: true, batchItem: { include: { batch: true } } }, orderBy: { id: 'desc' }, take: 1, }, records: { orderBy: { createdAt: 'desc' }, take: 20 }, }, orderBy: { createdAt: 'desc' }, }); }); it('adds real today delivery statistics and the latest successful send time to report tasks', async () => { const prisma = createPrismaMock(); const task = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved', }; const taskChannel = { id: 'channel-1', code: 'CMPP-A', name: '主通道' }; prisma.channelSignatureReportTask.findMany.mockResolvedValue([ { ...task, reportType: 'signature', drainageItemId: null, channel: taskChannel }, { ...task, id: 'report-task-2', reportType: 'drainage', drainageItemId: 'drain-1', channel: taskChannel }, ]); prisma.$queryRaw.mockResolvedValue([ { channelId: 'channel-1', signatureId: 'sig-1', drainageInfoId: null, total: 5, acceptedCount: 4, submitFailureCount: 1, successCount: 2, unknownCount: 1, failureCount: 1, lastSuccessfulSentAt: new Date('2026-07-27T01:00:00.000Z'), }, { channelId: 'channel-1', signatureId: 'sig-1', drainageInfoId: 'drain-1', total: 3, acceptedCount: 3, submitFailureCount: 0, successCount: 3, unknownCount: 0, failureCount: 0, lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), }, ]); const service = new ChannelsService(prisma as never); const result = await service.listReportTasks(undefined, undefined, 'channel-1'); expect(result[0]).toEqual( expect.objectContaining({ deliveryStats: { total: 8, acceptedCount: 7, submitFailureCount: 1, submitFailureRate: 12.5, successCount: 5, successRate: 71.4, unknownCount: 1, unknownRate: 14.3, failureCount: 1, failureRate: 14.3, }, lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), }), ); expect(result[1]).toEqual( expect.objectContaining({ deliveryStats: expect.objectContaining({ total: 3, submitFailureCount: 0, successCount: 3, successRate: 100, }), lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), }), ); }); it.each(['enterprise_signature', 'report_task'] as const)( 'changes channel report status from %s and recomputes the signature summary atomically', async (sourceEntry) => { const prisma = createPrismaMock(); const tx = { $executeRaw: jest.fn().mockResolvedValue(1), smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), }, smsChannel: { findUnique: jest .fn() .mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }), }, smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }), }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting', }), update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', }), create: jest.fn(), findMany: jest.fn().mockResolvedValue([ { id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }, }, ]), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, channelRouteRule: { findMany: jest .fn() .mockResolvedValue([ { group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }, ]), }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); await expect( service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry, }), ).resolves.toEqual([expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' })]); expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry, }), }); expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }), }); expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' }, }); }, ); it('uses the enterprise-signature save time when creating an approved carrier task', async () => { const prisma = createPrismaMock(); const tx = { $executeRaw: jest.fn().mockResolvedValue(1), smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), }, smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active', }), }, smsDrainageInfo: { findUnique: jest.fn() }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), update: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })), findMany: jest.fn().mockResolvedValue([ { id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }, }, ]), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, channelRouteRule: { findMany: jest.fn().mockResolvedValue([]) }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); await service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], sourceEntry: 'enterprise_signature', }); expect(tx.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', approvedAt: expect.any(Date), }), }); }); it('changes a drainage report task without overwriting the signature report summary', async () => { const prisma = createPrismaMock(); const tx = { $executeRaw: jest.fn().mockResolvedValue(1), smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn(), }, smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) }, smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }), }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting', }), update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }), create: jest.fn(), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); await expect( service.changeReportTaskStatuses({ items: [ { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved', }, ], reason: '引流信息已报备', }), ).resolves.toEqual([ { signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', carrier: null, status: 'approved', }, ]); expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', carrier: null, }, }); expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', }), }); expect(tx.smsSignature.update).not.toHaveBeenCalled(); }); beforeEach(() => { mockQueueAdd.mockClear(); mockQueueClose.mockClear(); mockJobRemove.mockClear(); mockFetch.mockClear(); mockRedisXadd.mockClear(); mockRedisSet.mockClear(); mockRedisEval.mockClear(); mockRedisDisconnect.mockClear(); global.fetch = mockFetch as never; }); it('rejects incomplete channel creation input with readable 400 errors', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow( 'Missing required channel fields', ); expect(prisma.smsChannel.create).not.toHaveBeenCalled(); }); it('replays every active channel connection after Gateway restart', async () => { const prisma = createPrismaMock(); const activeChannel = await prisma.smsChannel.findUnique(); prisma.smsChannel.findMany.mockResolvedValue([activeChannel]); const service = new ChannelsService(prisma as never); await ( service as unknown as { reconnectActiveChannelsAfterGatewayRestart(): Promise } ).reconnectActiveChannelsAfterGatewayRestart(); expect(prisma.smsChannel.findMany).toHaveBeenCalledWith({ where: { status: 'active' } }); expect(mockQueueAdd).toHaveBeenCalledWith( 'connect-channel', expect.objectContaining({ channelId: 'channel-1', reason: 'gateway_restarted', channel: expect.objectContaining({ rateLimitPerSecond: 100 }), }), expect.objectContaining({ jobId: expect.stringMatching(/^gateway-connect-channel-1-/), removeOnComplete: 1000, removeOnFail: 1000, }), ); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/connect', expect.objectContaining({ body: expect.stringContaining('"reason":"gateway_restarted"'), }), ); }); it('uses the direct Gateway control path when the Redis marker queue is unavailable', async () => { const prisma = createPrismaMock(); mockQueueAdd.mockRejectedValueOnce(new Error('redis unavailable')); const service = new ChannelsService(prisma as never); await expect( service.createChannel({ code: 'CMPP-DIRECT', name: '直连控制测试', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', srcId: '10690000', status: 'active', }), ).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', }), ); }); it('reuses a supplier state created concurrently by another API instance', async () => { const prisma = createPrismaMock(); prisma.cmppConnectionState.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'state-concurrent', applicationId: null }); prisma.cmppConnectionState.create.mockRejectedValueOnce({ code: 'P2002' }); const service = new ChannelsService(prisma as never); await expect( service.createChannel({ code: 'CMPP-CONCURRENT', name: '并发状态测试', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', srcId: '10690000', status: 'active', }), ).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ where: { id: 'state-concurrent' }, data: expect.objectContaining({ status: 'connecting' }), }); }); it('creates CMPP channels and route rules with first-version defaults', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.createChannel({ code: 'CMPP-A', name: '主通道', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', srcId: '10690000', desiredConnections: 2, windowSize: 32, rateLimitPerSecond: 750, config: { extensionDigits: 4 }, }); await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750, }); await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ data: expect.objectContaining({ protocol: 'CMPP', cmppVersion: '2.0', rateLimitPerSecond: 750, sendRegion: '全国', status: 'active', config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4, serviceId: 'SMS', }), }), }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', connectionId: 'channel-1:primary', status: 'connecting', desiredConnections: 2, currentConnections: 0, }), }); expect(mockQueueAdd).toHaveBeenCalledWith( 'connect-channel', expect.objectContaining({ messageType: 'ConnectChannel', channelId: 'channel-1', connectionId: 'channel-1:primary', reason: 'channel_created', channel: expect.objectContaining({ cmppVersion: '2.0' }), }), expect.objectContaining({ jobId: expect.stringMatching(/^gateway-connect-channel-1-/), removeOnComplete: 1000, removeOnFail: 1000, }), ); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"messageType":"ConnectChannel"'), }), ); expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({ data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 13, retryTimeLimitMinutes: 750, }), }); expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', channelId: undefined, carrier: 'mobile', priority: 100, status: 'active', }), }); }); it('defaults new channels to port 7890, CMPP and SMS while rejecting protocol overrides', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.createChannel({ code: 'CMPP-DEFAULT', name: '默认通道', gatewayHost: '127.0.0.1', account: 'sp', passwordCipher: 'secret', srcId: '10690000', protocol: 'HTTP', }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ data: expect.objectContaining({ gatewayPort: 7890, protocol: 'CMPP', config: expect.objectContaining({ serviceId: 'SMS' }), }), }); }); it('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.createChannel({ code: 'CMPP-3', name: '3.0通道', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', srcId: '10690000', cmppVersion: '3.0', }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ data: expect.objectContaining({ cmppVersion: '3.0' }), }); await expect( service.createChannel({ code: 'BAD', name: '非法版本', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', srcId: '10690000', cmppVersion: '1.0', }), ).rejects.toThrow('cmppVersion must be 2.0 or 3.0'); }); it('rejects invalid channel rate limits and extension digit counts', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); const channel = { code: 'CMPP-CONFIG', name: '配置校验通道', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', srcId: '10690000', }; await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow( 'rateLimitPerSecond must be between 1 and 2000', ); await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow( 'extensionDigits must be an integer between 0 and 20', ); await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow( 'serviceId must contain 1 to 10 ASCII characters', ); await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow( 'longMessageReceiptMode must be per_segment or message_level', ); }); it('updates CMPP channel configuration without requiring password changes', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await expect( service.updateChannel('channel-1', { name: '主通道-编辑', gatewayHost: '10.0.0.1', gatewayPort: 27890, carrier: 'all', sendRegion: '全国', account: 'sp-new', srcId: '10690001', desiredConnections: 3, windowSize: 64, rateLimitPerSecond: 320, config: { extensionDigits: 2 }, unitPrice: 4, }), ).resolves.toEqual( expect.objectContaining({ id: 'channel-1', name: '主通道-编辑', gatewayHost: '10.0.0.1', }), ); expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: expect.objectContaining({ name: '主通道-编辑', gatewayHost: '10.0.0.1', gatewayPort: 27890, carrier: 'all', passwordCipher: undefined, rateLimitPerSecond: 320, config: expect.objectContaining({ desiredConnections: 3, windowSize: 64, extensionDigits: 2 }), }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'sms_channel.update', resource: 'sms_channel', resourceId: 'channel-1', }), }); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', }), ); }); it('does not request a reconnect when only non-connection channel fields change', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.updateChannel('channel-1', { name: '主通道-新名称', carrier: 'unicom', sendRegion: '上海', rateLimitPerSecond: 200, unitPrice: 5, }); expect(prisma.smsChannel.update).toHaveBeenCalled(); expect(prisma.cmppConnectionState.create).not.toHaveBeenCalled(); expect(prisma.cmppConnectionState.update).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled(); }); it('does not request a reconnect when a full edit payload keeps connection settings unchanged', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.updateChannel('channel-1', { name: '主通道-完整保存', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'sp', passwordCipher: 'secret', cmppVersion: '2.0', desiredConnections: 1, windowSize: 16, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3, rateLimitPerSecond: 300, config: { serviceId: 'SMS' }, }); expect(prisma.smsChannel.update).toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled(); }); it('persists an arbitrary integer extension digit count within the supported range', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.updateChannel('channel-1', { config: { extensionDigits: 15 } }); expect(prisma.smsChannel.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ config: expect.objectContaining({ extensionDigits: 15 }) }), }), ); }); it('persists a message-level long-message receipt mode without requesting a reconnect', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.updateChannel('channel-1', { config: { longMessageReceiptMode: 'message_level' } }); expect(prisma.smsChannel.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }), }), }), ); expect(mockFetch).not.toHaveBeenCalled(); }); it('rejects invalid channel update ports', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow( 'gatewayPort must be an integer between 1 and 65535', ); expect(prisma.smsChannel.update).not.toHaveBeenCalled(); }); it('rejects direct single-channel route rules', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await expect( service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1', }), ).rejects.toThrow('Route rules can only bind channel groups'); expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); }); it('enforces single-carrier channel groups and compatible group items', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); expect(() => service.createGroup({ code: 'G-ALL', name: '三网组', carrier: 'all' })).toThrow( 'carrier must be mobile, unicom, or telecom', ); await service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东', priority: 10, }); expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({ data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东', }), }); expect(prisma.smsChannelGroupItem.create.mock.calls[0][0].data).not.toHaveProperty('rateLimitPerSecond'); await expect( service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' }), ).rejects.toThrow('Channel group items must use the same carrier'); const compatibleChannel = { id: 'channel-1', code: 'CMPP-A', name: '主通道', carrier: 'mobile', protocol: 'CMPP', gatewayHost: '127.0.0.1', gatewayPort: 17890, enterpriseCode: 'EC', account: 'sp', passwordCipher: 'secret', srcId: '10690000', cmppVersion: '3.0', rateLimitPerSecond: 100, unitPrice: 3, status: 'active', config: { serviceId: 'SMS' }, sendRegion: '山东', }; prisma.smsChannel.findUnique.mockResolvedValueOnce({ ...compatibleChannel, carrier: 'telecom' }); await expect( service.addGroupItem({ groupId: 'group-1', channelId: 'channel-x', carrier: 'mobile' }), ).rejects.toThrow('Channel carrier is not compatible'); prisma.smsChannel.findUnique.mockResolvedValue(compatibleChannel); prisma.smsChannelGroupItem.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'province-item', province: '山东' }); await expect( service.addGroupItem({ groupId: 'group-1', channelId: 'channel-2', carrier: 'mobile', province: '山东' }), ).rejects.toThrow('同一通道组内同一省份只能配置一个通道'); }); it('rejects province routes with mismatched channel sendRegion and duplicate national priorities', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-henan', carrier: 'all', sendRegion: '河南' }); await expect( service.addGroupItem({ groupId: 'group-1', channelId: 'channel-henan', carrier: 'mobile', province: '山东' }), ).rejects.toThrow('Province route must use a channel with the same sendRegion'); prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-national', carrier: 'all', sendRegion: '全国' }); prisma.smsChannelGroupItem.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'national-priority-1', province: null, priority: 1 }); await expect( service.addGroupItem({ groupId: 'group-1', channelId: 'channel-national', carrier: 'mobile', priority: 1 }), ).rejects.toThrow('同一通道组内全国通道优先级不能重复'); }); it('updates channel groups and replaces items with backend validation', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); prisma.smsChannel.findMany.mockResolvedValue([ { id: 'channel-sd', carrier: 'mobile', sendRegion: '山东' }, { id: 'channel-national', carrier: 'all', sendRegion: '全国' }, ]); await service.updateGroup('group-1', { name: '移动组更新', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750, items: [ { channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 }, { channelId: 'channel-national', carrier: 'mobile', priority: 1 }, ], }); expect(prisma.$transaction).toHaveBeenCalled(); const transactionCallback = prisma.$transaction.mock.calls[0][0]; const tx = { $executeRaw: jest.fn().mockResolvedValue(1), smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() }, smsChannelGroup: { update: jest.fn(), findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组更新', carrier: 'mobile', description: null, status: 'active', retryEnabled: true, retryTimeLimitMinutes: 750, items: [ { channelId: 'channel-national', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { code: 'CMPP-N', name: '全国通道' }, }, { channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10, weight: 1, isBackup: false, channel: { code: 'CMPP-SD', name: '山东通道' }, }, ], }), }, operationLog: { create: jest.fn() }, }; await transactionCallback(tx); expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }), }); for (const item of tx.smsChannelGroupItem.createMany.mock.calls[0][0].data) { expect(item).not.toHaveProperty('rateLimitPerSecond'); } expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'sms_channel_group.update', resource: 'sms_channel_group', resourceId: 'group-1', detail: expect.objectContaining({ before: expect.objectContaining({ name: '移动组' }), after: expect.objectContaining({ name: '移动组更新', items: expect.arrayContaining([ expect.objectContaining({ channelId: 'channel-national', priority: 1, channelName: '全国通道' }), ]), }), }), }), }); await expect( service.updateGroup('group-1', { carrier: 'mobile', items: [ { channelId: 'channel-sd', carrier: 'mobile', priority: 1 }, { channelId: 'channel-national', carrier: 'mobile', priority: 1 }, ], }), ).rejects.toThrow('同一通道组内全国通道优先级不能重复'); }); it('requires route rule carrier to match the channel group carrier', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await expect( service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'unicom' }), ).rejects.toThrow('Route rule carrier must match the channel group carrier'); expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); }); it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }], }); prisma.channelRouteRule.findMany.mockResolvedValueOnce([ { applicationId: 'app-active' }, { applicationId: 'app-active' }, { applicationId: 'app-deleted' }, { applicationId: 'app-missing' }, ]); prisma.smsApplication.findMany.mockResolvedValueOnce([ { id: 'app-active', status: 'active' }, { id: 'app-deleted', status: 'deleted' }, ]); prisma.smsSubmitRecord.count.mockResolvedValueOnce(2); await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({ groupId: 'group-1', groupName: '移动主通道组', normalApplicationCount: 1, deletedApplicationCount: 2, channelCount: 2, pendingSupplierSubmitCount: 2, }); expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({ where: { channelGroupId: 'group-1', submitStatus: 'queued' }, }); }); it('logically deletes channel groups without removing application bindings or group items', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' }); const operationLogCreate = jest.fn(); prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]); prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]); prisma.$transaction.mockImplementationOnce((callback) => callback({ smsChannelGroup: { update: groupUpdate }, operationLog: { create: operationLogCreate }, }), ); await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' }); expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } }); expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled(); expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled(); expect(operationLogCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'sms_channel_group.delete', detail: expect.objectContaining({ deletionMode: 'soft_delete', impact: expect.objectContaining({ normalApplicationCount: 1 }), }), }), }); }); it('upserts signature report material per channel field', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.upsertReportMaterial({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1', }); expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith({ where: { signatureId_channelId_fieldCode: { signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' }, }, update: { fieldValue: '营业执照', fileObjectId: 'file-1' }, create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' }), }); }); it('records report task creation, export, receipt import, and signature status synchronization', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', createdById: 'user-1', }); await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 }); await service.importReportReceipt('report-task-1', { fileName: 'receipt.csv', rowCount: 10, successCount: 9, failedCount: 1, reason: 'one rejected', }); expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'create', statusAfter: 'pending' }), }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, data: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }), }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, data: expect.objectContaining({ status: 'partial', reason: 'one rejected', approvedAt: null }), }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'reporting' }, }); }); it('parses text receipt imports and derives report task status', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.importReportReceipt('report-task-1', { fileObjectId: 'file-1', fileName: 'receipt.csv', fileContent: 'phone,status\n13800138000,success\n13900139000,failed\n13700137000,通过', reason: 'carrier receipt', }); expect(prisma.reportReceiptImport.create).toHaveBeenCalledWith({ data: expect.objectContaining({ fileObjectId: 'file-1', fileName: 'receipt.csv', rowCount: 3, successCount: 2, failedCount: 1, result: expect.objectContaining({ hasHeader: true, rows: expect.any(Array) }), }), }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, data: expect.objectContaining({ status: 'partial', reason: 'carrier receipt', approvedAt: null }), }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'reporting' }, }); }); it('updates channel status with operation logs', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.changeChannelStatus('channel-1', { status: 'disabled', operatorId: 'admin-1', reason: 'maintenance', }); expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'disabled' } }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'admin-1', action: 'sms_channel.disabled', resource: 'sms_channel', resourceId: 'channel-1', }), }); await service.changeChannelStatus('channel-1', { status: 'active', operatorId: 'admin-1', reason: 'resume' }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', connectionId: 'channel-1:primary', status: 'connecting', }), }); expect(mockQueueAdd).toHaveBeenCalledWith( 'connect-channel', expect.objectContaining({ messageType: 'ConnectChannel', channelId: 'channel-1', reason: 'channel_enabled', }), expect.objectContaining({ jobId: expect.stringMatching(/^gateway-connect-channel-1-/), removeOnComplete: 1000, removeOnFail: 1000, }), ); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"reason":"channel_enabled"'), }), ); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"reason":"channel_disabled"'), }), ); }); it('reconnects active failed channels and disconnects inactive live channels during reconciliation', async () => { const prisma = createPrismaMock(); prisma.smsChannel.findMany.mockResolvedValue([ { ...(await prisma.smsChannel.findUnique()), id: 'channel-active', status: 'active', connectionStates: [ { id: 'state-active', connectionId: 'channel-active:primary', status: 'failed', currentConnections: 0, desiredConnections: 1, lastHeartbeatAt: null, nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'), }, ], }, { ...(await prisma.smsChannel.findUnique()), id: 'channel-disabled', status: 'disabled', connectionStates: [ { id: 'state-disabled', connectionId: 'channel-disabled:primary', status: 'connected', currentConnections: 1, desiredConnections: 1, lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), nextReconnectAt: null, }, ], }, ]); const service = new ChannelsService(prisma as never); const result = await service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')); expect(result).toEqual({ scanned: 2, reconnectRequested: 1, disconnectRequested: 1 }); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/connect', expect.objectContaining({ body: expect.stringContaining('"reason":"automatic_reconnect"'), }), ); expect(mockFetch).toHaveBeenCalledWith( 'http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({ body: expect.stringContaining('"reason":"inactive_channel_reconcile"'), }), ); expect(mockRedisSet).toHaveBeenCalledTimes(2); expect(mockRedisEval).toHaveBeenCalledTimes(2); }); it('does not reconnect a fresh healthy supplier connection', async () => { const prisma = createPrismaMock(); prisma.smsChannel.findMany.mockResolvedValue([ { ...(await prisma.smsChannel.findUnique()), status: 'active', config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 }, connectionStates: [ { connectionId: 'channel-1:primary', status: 'connected', currentConnections: 1, desiredConnections: 1, lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), nextReconnectAt: null, }, ], }, ]); const service = new ChannelsService(prisma as never); await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))).resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0, }); expect(mockFetch).not.toHaveBeenCalled(); expect(mockRedisSet).not.toHaveBeenCalled(); }); it('skips duplicate reconciliation when another API instance owns the Redis lease', async () => { const prisma = createPrismaMock(); prisma.smsChannel.findMany.mockResolvedValue([ { ...(await prisma.smsChannel.findUnique()), status: 'active', connectionStates: [], }, ]); mockRedisSet.mockResolvedValueOnce(null); const service = new ChannelsService(prisma as never); await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))).resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0, }); expect(mockFetch).not.toHaveBeenCalled(); expect(mockRedisEval).not.toHaveBeenCalled(); }); it('stores supplier heartbeat as a connected state and heartbeat audit event', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.upsertConnectionState({ channelId: 'channel-1', connectionId: 'channel-1:primary', status: 'heartbeat', currentConnections: 1, lastHeartbeatAt: '2026-07-23T11:00:00.000Z', }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'connected', lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'), }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'cmpp_connection.heartbeat' }), }); }); it('updates every supplier heartbeat without flooding operation logs', async () => { const prisma = createPrismaMock(); prisma.cmppConnectionState.findFirst.mockResolvedValue({ id: 'state-1', applicationId: null, lastHeartbeatAt: new Date('2026-07-23T10:59:30.000Z'), }); const service = new ChannelsService(prisma as never); await service.upsertConnectionState({ channelId: 'channel-1', connectionId: 'channel-1:primary', status: 'heartbeat', currentConnections: 1, lastHeartbeatAt: '2026-07-23T11:00:00.000Z', }); expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ where: { id: 'state-1' }, data: expect.objectContaining({ status: 'connected', lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'), }), }); expect(prisma.operationLog.create).not.toHaveBeenCalled(); }); it('copies channels with report field configuration and report materials', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' }); expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本', status: 'disabled' })); expect(prisma.$transaction).toHaveBeenCalled(); }); it('soft deletes channels through status change', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.deleteChannel('channel-1', { operatorId: 'admin-1', status: 'deleted' }); expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } }); }); it('queues channel test SMS through real message records and gateway stream', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); const result = await service.testChannel('channel-1', { phoneNumber: '18821203795', content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。', operatorId: 'admin-1', }); expect(result).toEqual( expect.objectContaining({ channelId: 'channel-1', status: 'submit_queued', submitted: 1, testNo: expect.stringMatching(/^CHTEST-/), }), ); expect(prisma.tenant.findFirst).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '18821203795', channelId: 'channel-1', status: 'submit_queued', submitStatus: 'queued', }), }); expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', sessionId: 'session-1', submitStatus: 'queued', }), }); expect(mockQueueAdd).toHaveBeenCalledWith( 'submit-command', expect.objectContaining({ messageType: 'SubmitCommand', channelId: 'channel-1', phoneNumber: '18821203795', content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。', upstream: expect.objectContaining({ cmppVersion: '2.0', gatewayHost: '127.0.0.1' }), }), ); expect(mockRedisXadd).toHaveBeenCalledWith( 'gateway.submit.commands', '*', 'messageType', 'SubmitCommand', 'data', expect.stringContaining('"phoneNumber":"18821203795"'), ); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'sms_channel.test_submit', resource: 'sms_channel', resourceId: 'channel-1', detail: expect.objectContaining({ testNo: expect.stringMatching(/^CHTEST-/) }), }), }); }); it('rejects channel test SMS when no CMPP connection is online', async () => { const prisma = createPrismaMock(); prisma.smsChannel.findUnique.mockResolvedValueOnce({ ...(await prisma.smsChannel.findUnique()), connectionStates: [{ connectionId: 'conn-a', status: 'failed', currentConnections: 0 }], }); const service = new ChannelsService(prisma as never); await expect( service.testChannel('channel-1', { phoneNumber: '18821203795', content: '测试短信', }), ).rejects.toThrow('通道当前没有可用 CMPP 连接'); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); expect(mockRedisXadd).not.toHaveBeenCalled(); }); it('upserts and lists CMPP connection states', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.upsertConnectionState({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'online', desiredConnections: 2, currentConnections: 1, }); await service.listChannelConnections('channel-1'); await service.listTenantConnections('tenant-1'); await service.listChannelConnectionLogs('channel-1'); expect(prisma.cmppConnectionState.findFirst).toHaveBeenCalledWith({ where: { applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a' }, }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected', lastError: null, }), }); expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1' }, orderBy: { updatedAt: 'desc' }, }); expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1' }, include: { channel: true }, orderBy: { updatedAt: 'desc' }, }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'cmpp_connection.connected', resource: 'cmpp_connection', resourceId: 'channel-1:conn-a', }), }); expect(prisma.operationLog.findMany).toHaveBeenCalled(); }); it('marks stale connecting CMPP connections as failed with operation logs', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); const now = new Date('2026-07-06T10:00:45.000Z'); prisma.cmppConnectionState.findMany.mockResolvedValueOnce([ { id: 'conn-state-1', tenantId: 'tenant-1', applicationId: null, channelId: 'channel-1', connectionId: 'channel-1:primary', status: 'connecting', desiredConnections: 1, currentConnections: 0, updatedAt: new Date('2026-07-06T10:00:00.000Z'), }, ]); await expect(service.markTimedOutConnectingChannels(now)).resolves.toEqual({ checked: 1, failed: 1 }); expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({ where: { status: 'connecting', updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') }, }, select: expect.objectContaining({ id: true, channelId: true, connectionId: true, updatedAt: true, }), take: 100, }); expect(prisma.cmppConnectionState.updateMany).toHaveBeenCalledWith({ where: { id: 'conn-state-1', status: 'connecting', updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') }, }, data: expect.objectContaining({ status: 'failed', currentConnections: 0, lastDisconnectedAt: now, lastError: 'Gateway connection request timed out after 30 seconds', }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', action: 'cmpp_connection.failed', resource: 'cmpp_connection', resourceId: 'channel-1:channel-1:primary', detail: expect.objectContaining({ reason: 'connect_timeout', timeoutMs: 30000, status: 'failed', previousStatus: 'connecting', }), }), }); }); it('does not write timeout logs when a connecting state is already changed by gateway callback', async () => { const prisma = createPrismaMock(); prisma.cmppConnectionState.updateMany.mockResolvedValueOnce({ count: 0 }); prisma.cmppConnectionState.findMany.mockResolvedValueOnce([ { id: 'conn-state-1', tenantId: 'tenant-1', applicationId: null, channelId: 'channel-1', connectionId: 'channel-1:primary', desiredConnections: 1, currentConnections: 0, updatedAt: new Date('2026-07-06T10:00:00.000Z'), }, ]); const service = new ChannelsService(prisma as never); await expect(service.markTimedOutConnectingChannels(new Date('2026-07-06T10:00:45.000Z'))).resolves.toEqual({ checked: 1, failed: 0, }); expect(prisma.operationLog.create).not.toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'cmpp_connection.failed' }), }); }); });