import { ChannelsService } from './channels.service'; const mockQueueAdd = jest.fn().mockResolvedValue(undefined); 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 mockRedisDisconnect = jest.fn(); jest.mock('bullmq', () => ({ Queue: jest.fn().mockImplementation(() => ({ add: mockQueueAdd, close: mockQueueClose, })), })); jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({ xadd: mockRedisXadd, disconnect: mockRedisDisconnect, }))); function createPrismaMock() { const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' }; const channel = { 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: '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 { $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({ id: 'channel-1', ...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 }), 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(), 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: '执照文件' }]), }, 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), 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' }), }, 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 })), }, 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('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 = { 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 }, include: { signature: true, channel: true, drainageInfo: true }, orderBy: { createdAt: 'desc' }, }); }); it('changes channel report status and recomputes the signature summary atomically', async () => { const prisma = createPrismaMock(); const tx = { 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', 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', status: 'reporting' }), update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }), create: jest.fn(), findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: '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', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).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: 'enterprise_signature' }) }); expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); }); it('changes a drainage report task without overwriting the signature report summary', async () => { const prisma = createPrismaMock(); const tx = { 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', status: 'approved' }]); expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } }); 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(); mockFetch.mockClear(); mockRedisXadd.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 }), }), { jobId: 'channel-1:primary:connect' }); expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ body: expect.stringContaining('"reason":"gateway_restarted"'), })); }); 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 }), }), }); 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' }), }), { jobId: 'channel-1:primary:connect' }); 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('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'); }); 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', }), }); }); 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('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 = { smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() }, smsChannelGroup: { update: jest.fn(), findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }), }, }; 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'); } 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('deletes channel groups only when no active route rule is bound', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); await service.deleteGroup('group-1'); expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } }); expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } }); prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' }); await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules'); }); 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', 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: { status: 'exporting', reason: undefined }, }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, data: { status: 'partial', reason: 'one rejected' }, }); 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: { status: 'partial', reason: 'carrier receipt' }, }); 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', }), { jobId: 'channel-1:primary:connect' }); expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"reason":"channel_enabled"'), })); }); 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' }), }); }); });