import { createHash } from 'node:crypto'; import { Prisma } from '@prisma/client'; import { BillingService } from '../billing/billing.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { SendChainService } from './send-chain.service'; function createPrismaMock() { const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 }; const message = { id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', templateId: 'tpl-1', messageId: 'MSG-1', phoneNumber: '13800000001', content: 'hello', billingUnits: 1, unitPrice: 3, amountCents: 3, status: 'queued', queuePriority: 'normal', submitId: 'SUB-1', gatewayMessageId: 'GW-1', channelId: 'channel-1', template: { signature: { id: 'sig-1', name: '签名' } }, }; const channel = { id: 'channel-1', code: 'CMPP-A', account: 'cmpp-account', srcId: '10690000', rateLimitPerSecond: 100, unitPrice: 3, status: 'active', carrier: 'mobile', sendRegion: '全国', gatewayHost: '127.0.0.1', gatewayPort: 17890, protocol: 'CMPP', passwordCipher: 'secret', cmppVersion: '3.0', config: { serviceId: 'SMS' }, connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }], }; const route = { id: 'route-1', tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', priority: 100, status: 'active', group: { id: 'group-1', name: '默认通道组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }], }, }; const prisma = { tenant: { findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }), }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal' }), findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }]), findFirst: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', cmppEnterpriseCode: 'SP0001', secretHash: 'secret-hash', status: 'active', interfaceEnabled: true, cmppMaxConnections: 2, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }), }, smsTemplate: { findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: 'hello', auditStatus: 'approved', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' }, }), findFirst: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', content: 'hello', auditStatus: 'approved', signature: { auditStatus: 'approved', reportStatus: 'reporting' }, }), findMany: jest.fn().mockResolvedValue([]), }, smsSignature: { findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }), }, smsDrainageInfo: { findMany: jest.fn().mockResolvedValue([]), }, smsSendTask: { findUnique: jest.fn().mockResolvedValue(null), }, smsBatchTask: { create: jest.fn().mockResolvedValue(task), findUnique: jest.fn().mockResolvedValue(task), findFirst: jest.fn().mockResolvedValue(task), findMany: jest.fn(), update: jest.fn().mockResolvedValue(task), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, smsApiRequest: { create: jest.fn().mockResolvedValue({ id: 'request-1' }), findMany: jest.fn(), }, smsMessageRecord: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })), createMany: jest.fn().mockResolvedValue({ count: 2 }), findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]), count: jest.fn().mockResolvedValue(1), findUnique: jest.fn().mockResolvedValue(message), findFirst: jest.fn().mockResolvedValue(message), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]), }, channelRouteRule: { findFirst: jest.fn().mockResolvedValue(route), findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', applicationId: 'app-1' }]), }, phoneCarrierRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]), }, phoneSegment: { findMany: jest.fn().mockResolvedValue([{ prefix: '1380000', province: '山东', city: '济南' }]), }, smsChannel: { findFirst: jest.fn().mockResolvedValue(channel), findUnique: jest.fn().mockResolvedValue(channel), }, cmppSubmitSession: { upsert: jest.fn().mockResolvedValue({ id: 'session-1' }), }, smsSubmitRecord: { create: jest.fn().mockResolvedValue({ id: 'submit-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }), findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve( where.retryOfSubmitRecordId ? null : { id: 'submit-1', messageRecordId: 'record-1', channelId: 'channel-1', submitId: 'SUB-1', submitStatus: 'accepted' }, )), count: jest.fn().mockResolvedValue(1), findMany: jest.fn().mockResolvedValue([]), }, smsMessageSegmentAudit: { upsert: jest.fn().mockResolvedValue({ id: 'segment-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), }, cmppInboundLongMessage: { create: jest.fn(), findFirst: jest.fn().mockResolvedValue(null), update: jest.fn(), updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, cmppInboundLongMessageSegment: { create: jest.fn(), findMany: jest.fn().mockResolvedValue([]), }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }), findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))), }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }), findUnique: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn(), }, smsUplinkMessage: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })), findMany: jest.fn(), findUnique: jest.fn().mockResolvedValue({ id: 'uplink-1', matchStatus: 'matched', matchCandidates: [] }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })), }, smsUplinkMatchCandidate: { createMany: jest.fn().mockResolvedValue({ count: 1 }), findFirst: jest.fn().mockResolvedValue({ id: 'candidate-1', uplinkMessageId: 'uplink-1', tenantId: 'tenant-1', applicationId: 'app-1', messageRecordId: 'record-1', matchSource: 'phone_window', confidence: 55, reason: '手机号 72 小时窗口候选下发 MSG-1', status: 'pending', application: { id: 'app-1', name: '应用A', cmppAccount: '100001' }, messageRecord: { id: 'record-1', messageId: 'MSG-1', content: 'hello' }, uplinkMessage: { id: 'uplink-1', tenantId: null, applicationId: null, messageRecordId: null, messageId: null, channelId: 'channel-1', phoneNumber: '13800000001', destId: '10690000', content: '回复TD', matchStatus: 'ambiguous', receivedAt: new Date('2026-07-08T12:00:00.000Z'), }, }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }), }, cmppDownstreamDelivery: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', ...data, createdAt: new Date(), updatedAt: new Date() })), findUnique: jest.fn().mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 0, manualRetryCount: 0, status: 'failed', updatedAt: new Date('2026-07-21T08:00:00.000Z'), lastError: null, payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' }, application: { cmppAccount: '100001' }, }), findMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, cmppDownstreamDeliveryAttempt: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-attempt-1' }), findMany: jest.fn().mockResolvedValue([]), }, upstreamReceiptInbox: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'receipt-inbox-1', attemptCount: 0, receivedAt: new Date(), ...create, })), findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'receipt-inbox-1', ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, gatewaySubmitDeadLetter: { upsert: jest.fn().mockResolvedValue({ id: 'dead-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', channelId: 'channel-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1', status: 'pending', manualRetryCount: 0, commandPayload: { schemaVersion: 'v1', messageType: 'SubmitCommand', traceId: 'trace-1', messageId: 'MSG-1', channelId: 'channel-1', createdAt: '2026-07-08T12:00:00.000Z', tenantId: 'tenant-1', applicationId: 'app-1', submitId: 'SUB-1', phoneNumber: '13800000001', content: 'hello', signature: '签名', templateId: 'tpl-1', billingUnits: 1, queuePriority: 'normal', route: { channelCode: 'CMPP-A', cmppAccountCode: 'account-a', priority: 0 }, cmpp: { serviceId: 'SMS', srcId: '10690000', registeredDelivery: 1, msgFmt: 8 }, upstream: { gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'account-a', passwordCipher: 'secret', cmppVersion: '3.0' }, retry: { attempt: 0, maxAttempts: 1 }, }, }), update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), findMany: jest.fn().mockResolvedValue([]), }, gatewayDownstreamRecoveryStatus: { findUnique: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({ id: 'recover-1', tenantId: 'tenant-1', applicationId: 'app-1', account: '100001', gatewayInstanceId: 'gateway-a', state: 'waiting_connection', lockOwner: 'gateway-a', lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), attemptCount: 2, failureCategory: 'client_disconnected', nextRetryAt: new Date('2026-07-08T12:10:00.000Z'), lastError: 'downstream client is not connected', }), }, smsBillingRecord: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'bill-1' }), update: jest.fn().mockResolvedValue({ id: 'bill-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, accountTransaction: { findFirst: jest.fn().mockResolvedValue(null), }, operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }), }, enterpriseBlacklist: { findMany: jest.fn().mockResolvedValue([]), }, globalBlacklist: { findMany: jest.fn().mockResolvedValue([]), }, $queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]), $executeRaw: jest.fn().mockResolvedValue(1), $transaction: jest.fn(), }; prisma.$transaction.mockImplementation((operations: any) => typeof operations === 'function' ? operations(prisma) : Promise.all(operations)); return prisma; } function createService( prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }, ) { const billing = { estimateSmsCost: jest.fn().mockReturnValue({ billingUnitsPerMessage: 1, totalBillingUnits: 2, unitPrice: 3, amountCents: 6, }), checkAccount: jest.fn().mockResolvedValue({ canSend: true }), freeze: jest.fn().mockResolvedValue({ id: 'tx-freeze' }), release: jest.fn().mockResolvedValue({ id: 'tx-release' }), charge: jest.fn().mockResolvedValue({ id: 'tx-charge' }), refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }), } as unknown as BillingService; const riskReview = { evaluateTask: jest.fn().mockResolvedValue({ status: 'approved', reason: null, task: { id: 'risk-task-1' }, }), aggregateTemplateMismatch: jest.fn().mockResolvedValue({ id: 'review-task-1', reviewReason: '企业应用已配置模板不匹配进入人工审核', }), } as unknown as RiskReviewService; const service = new SendChainService(prisma as never, billing, riskReview, openApi as never); service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true }); service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined); return { service, prisma, billing, riskReview }; } describe('SendChainService', () => { it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => { const { service, prisma, riskReview, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 }); await service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001', '13800000001', '13800000002'], sourceIp: '127.0.0.1', userAgent: 'jest', }); expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['13800000001', '13800000002'] })); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneTotal: 2, status: 'ready', progressTotal: 2, auditStatus: 'approved' }), }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }), expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }), ]), }); expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' })); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => { const { service, prisma, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]); (billing.estimateSmsCost as jest.Mock).mockReturnValue({ billingUnitsPerMessage: 1, totalBillingUnits: 1, unitPrice: 3, amountCents: 3, }); await service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001', '23800000002', '13800000002'], }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }), expect.objectContaining({ phoneNumber: '23800000002', status: 'submit_failed', submitStatus: 'rejected', errorCode: 'INVALID_PHONE', amountCents: 0, }), expect.objectContaining({ phoneNumber: '13800000002', status: 'submit_failed', submitStatus: 'rejected', errorCode: 'GLOBAL_BLACKLIST', amountCents: 0, }), ]), }); expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 })); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); it('persists the review task id on every message waiting for manual review', async () => { const { service, prisma, riskReview } = createService(); (riskReview.evaluateTask as jest.Mock).mockResolvedValue({ status: 'pending_review', reason: '命中人工审核规则', task: { id: 'review-task-1' }, }); await service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001'], }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ phoneNumber: '13800000001', status: 'pending_review', reviewTaskId: 'review-task-1', })], }); }); it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => { const { service, prisma, billing } = createService(); prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]); await expect(service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001', '13800000002'], })).rejects.toThrow('应用当日发送上限1条'); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.createMany).not.toHaveBeenCalled(); expect(billing.freeze).not.toHaveBeenCalled(); }); it('recognizes an approved template for public HTTP content and reads back the api task', async () => { const { service, prisma, riskReview } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findFirst.mockResolvedValue({ id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' }, }); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, }); await service.createHttpBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'], sourceIp: '127.0.0.1', clientMessageId: 'client-http-1', }); expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ templateId: 'tpl-http', variables: { code: '123456' }, })); expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'task-1', sourceType: 'api' }), })); }); it('persists the unique longest approved drainage URL match on new message records', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved', content: '【签名】详情请访问 https://a.example/landing', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' }, }); prisma.smsDrainageInfo.findMany.mockResolvedValue([ { id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') }, { id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') }, ]); await service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'], }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ signatureId: 'sig-1', drainageInfoId: 'drain-long' })], }); }); it('rejects a task when the submitted content no longer matches the selected approved template', async () => { const { service, prisma, riskReview } = createService(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: '【签名】验证码${code}', auditStatus: 'approved', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, }); await expect(service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: '【签名】被篡改的正文', phones: ['13800000001'], })).rejects.toThrow('短信内容与选定的审核模板不匹配'); expect(riskReview.evaluateTask).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); }); it('rejects free content without an approved leading signature', async () => { const { service, prisma } = createService(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send', }); prisma.smsSignature.findFirst.mockResolvedValue(null); await expect(service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: '没有签名的自由内容', phones: ['13800000001'], })).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头'); }); it('allows signed free content only when the application explicitly uses direct send', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send', }); prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' }); await expect(service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】允许直接发送的自由内容', phones: ['13800000001'], })).resolves.toBeDefined(); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ signatureId: 'sig-1' })], }); }); it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => { const { service, prisma, riskReview } = createService(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: '【签名】详情 https://blocked.example', auditStatus: 'approved', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, }); prisma.smsDrainageInfo.findMany.mockResolvedValue([ { id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') }, ]); await expect(service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: '【签名】详情 https://blocked.example', phones: ['13800000001'], })).resolves.toBeDefined(); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }), }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`), })], }); expect(riskReview.evaluateTask).not.toHaveBeenCalled(); }); it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => { const { service, prisma, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); const scheduledAt = new Date(Date.now() + 60_000).toISOString(); await service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001'], sendMode: 'scheduled', scheduledAt, }); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'scheduled', scheduledAt: expect.any(Date) }), }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ status: 'scheduled' })], }); expect(billing.freeze).not.toHaveBeenCalled(); expect(service.enqueueBatchTask).not.toHaveBeenCalled(); prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' }]); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); await expect(service.dispatchDueScheduledTasks(new Date(Date.now() + 120_000))).resolves.toEqual({ dispatched: 1, results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }], }); expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' })); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { batchTaskId: 'task-1', status: 'scheduled' }, data: { status: 'queued' }, }); expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({ where: expect.objectContaining({ id: 'task-1', status: 'scheduled' }), data: { status: 'scheduled_dispatching' }, }); }); it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => { const { service, prisma, billing } = createService(); const dueTask = { id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', scheduledAt: new Date(Date.now() - 1_000), updatedAt: new Date(Date.now() - 1_000), }; prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]); prisma.smsBatchTask.updateMany .mockResolvedValueOnce({ count: 1 }) .mockResolvedValueOnce({ count: 0 }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); const results = await Promise.all([ service.dispatchDueScheduledTasks(new Date()), service.dispatchDueScheduledTasks(new Date()), ]); expect(results.map((item) => item.dispatched).sort()).toEqual([0, 1]); expect(billing.freeze).toHaveBeenCalledTimes(1); expect(service.enqueueBatchTask).toHaveBeenCalledTimes(1); }); it('recovers a stale claimed task without freezing its balance twice', async () => { const { service, prisma, billing } = createService(); const now = new Date(); prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled_dispatching', scheduledAt: new Date(now.getTime() - 300_000), updatedAt: new Date(now.getTime() - 300_000), }]); prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); await expect(service.dispatchDueScheduledTasks(now)).resolves.toEqual({ dispatched: 1, results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }], }); expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({ where: expect.objectContaining({ id: 'task-1', status: 'scheduled_dispatching', updatedAt: { lt: expect.any(Date) } }), data: { status: 'scheduled_recovering' }, }); expect(billing.freeze).not.toHaveBeenCalled(); }); it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-free', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', }]); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable')); await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({ dispatched: 0, results: [{ taskId: 'task-free', status: 'retrying', reason: 'Redis unavailable' }], }); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-free' }, data: { status: 'scheduled_dispatching', rejectReason: '调度将在超时后恢复:Redis unavailable' }, }); }); it('automatically scans and dispatches due scheduled tasks after application startup', async () => { jest.useFakeTimers(); const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS; const { service } = createService(); const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] }); try { process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false'; process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true'; process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000'; service.onModuleInit(); await jest.advanceTimersByTimeAsync(1_000); expect(dispatch).toHaveBeenCalledTimes(1); await service.onModuleDestroy(); } finally { if (previousReceiptEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousReceiptEnabled; if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled; if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS; else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval; jest.useRealTimers(); } }); it('cancels scheduled tasks before dispatch', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', status: 'scheduled' }); await service.cancelBatchTask('task-1'); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { batchTaskId: 'task-1', status: 'scheduled' }, data: { status: 'canceled', errorMessage: '定时任务已取消' }, }); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'canceled', canceledAt: expect.any(Date) }, }); }); it('lists only client-created batch tasks for task progress', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-client', tenantId: 'tenant-1', sourceType: 'client' }]); prisma.smsMessageRecord.groupBy.mockResolvedValue([]); await service.listBatchTasks('tenant-1', 'queued'); expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, })); }); it('does not expose CMPP internal tasks through client task detail or messages', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findFirst.mockResolvedValue(null); await expect(service.getBatchTask('task-cmpp', 'tenant-1', 'client')).rejects.toThrow('SMS batch task not found'); await expect(service.listClientTaskMessages('task-cmpp', 'tenant-1')).rejects.toThrow('SMS batch task not found'); expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'task-cmpp', tenantId: 'tenant-1', sourceType: 'client' }, })); expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled(); }); it('paginates the real phone list for an admin batch task', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', sourceType: 'client' }); prisma.smsMessageRecord.findMany.mockResolvedValue([ { id: 'record-1', phoneNumber: '13800000001', province: '上海', carrier: 'mobile', status: 'delivered' }, ]); prisma.smsMessageRecord.count.mockResolvedValue(21); await expect(service.listAdminBatchTaskMessages('task-1', '138', 2, 20)).resolves.toEqual({ items: [expect.objectContaining({ phoneNumber: '13800000001' })], total: 21, page: 2, pageSize: 20, }); expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({ where: { batchTaskId: 'task-1', phoneNumber: { contains: '138' } }, select: { id: true, phoneNumber: true, province: true, carrier: true, status: true, }, orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], skip: 20, take: 20, }); }); it('terminates non-final tasks by canceling unsubmitted messages', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' }); service['refreshTaskProgress'] = jest.fn().mockResolvedValue(undefined); await service.terminateBatchTask('task-1'); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { batchTaskId: 'task-1', status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } }, data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' }, }); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'canceled', canceledAt: expect.any(Date), rejectReason: '运营终止任务' }, }); }); it('blocks sending when enterprise certification is not approved', async () => { const { service, prisma } = createService(); prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' }); await expect( service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001'], }), ).rejects.toThrow('企业认证未通过,不能发送短信'); }); it('blocks sending when application interface is disabled', async () => { const { service, prisma } = createService(); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: false, customerUnitPrice: 3, queuePriority: 'normal', }); await expect( service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', content: 'hello', phones: ['13800000001'], }), ).rejects.toThrow('短信应用接口未开通,不能发送短信'); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); }); it('returns the application enterprise code after Gateway authentication', async () => { const { service } = createService(); await expect(service.authenticateInboundApplication({ account: '100001', password: 'secret-hash', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ account: '100001', enterpriseCode: 'SP0001', maxConnections: 2, status: 'authenticated', })); }); it('rejects Gateway authentication when application interface is disabled', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', secretHash: 'secret-hash', status: 'active', interfaceEnabled: false, ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); await expect(service.authenticateInboundApplication({ account: '100001', password: 'secret-hash', remoteIp: '127.0.0.1', })).rejects.toThrow('CMPP interface is disabled for this application'); }); it('rejects new submissions synchronously when the application interface was disabled after bind', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', secretHash: 'secret-hash', status: 'active', interfaceEnabled: false, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', interfaceEnabled: false, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: { enabled: false }, }); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: 'hello', remoteIp: '127.0.0.1', })).rejects.toThrow('CMPP account is disabled for new submissions'); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => { const prisma = createPrismaMock(); const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ queued: true }) }; const { service } = createService(prisma, openApi); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: false, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: { enabled: true }, }); await service['queueAndTryDownstreamDelivery']({ tenantId: 'tenant-1', applicationId: 'app-1', messageRecordId: 'record-1', messageId: 'MSG-1', deliveryType: 'receipt', payload: { receiptStatus: 'delivered' }, }); expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({ applicationId: 'app-1', eventType: 'receipt', })); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); it('splits every destination in one inbound CMPP Submit into an independent real message record', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', secretHash: 'secret-hash', status: 'active', interfaceEnabled: true, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); let taskIndex = 0; prisma.smsBatchTask.create.mockImplementation(({ data }) => { taskIndex += 1; return Promise.resolve({ id: `task-${taskIndex}`, ...data }); }); let messageIndex = 0; prisma.smsMessageRecord.create.mockImplementation(({ data }) => { messageIndex += 1; return Promise.resolve({ id: `record-${messageIndex}`, messageId: data.messageId, ...data }); }); const result = await service.submitInboundMessage({ account: '100001', phoneNumbers: ['13800000001', '13900000002'], content: 'hello', sequenceId: 777823876, remoteIp: '127.0.0.1', }); expect(result).toEqual(expect.objectContaining({ accepted: true, phoneCount: 2, messages: [ expect.objectContaining({ phoneNumber: '13800000001', messageRecordId: 'record-1' }), expect.objectContaining({ phoneNumber: '13900000002', messageRecordId: 'record-2' }), ], })); expect(result.messageId).toBe(result.messages[0].messageId); expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(2); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => { const { service, prisma, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); const segments: Array> = []; const group = { id: 'long-group-1', tenantId: 'tenant-1', applicationId: 'app-1', groupKey: 'group-key', account: '100001', srcId: null, phoneNumbers: ['13800000001'], concatReference: 16, segmentTotal: 2, msgFmt: 8, messageId: 'MSG-LONG-1', status: 'collecting', response: null, expiresAt: new Date(Date.now() + 300_000), createdAt: new Date(), segments, }; prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve( segments.length ? { ...group, segments: [...segments] } : null, )); prisma.cmppInboundLongMessage.create.mockResolvedValue(group); prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => { const segment = { id: `segment-${data.segmentIndex}`, ...data }; segments.push(segment); return Promise.resolve(segment); }); prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve( [...segments].sort((a, b) => a.segmentIndex - b.segmentIndex), )); prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => { Object.assign(group, data); return Promise.resolve({ ...group }); }); const first = await service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: '【签名】第一片', sequenceId: 101, remoteIp: '127.0.0.1', longMessage: { reference: 16, total: 2, index: 1, format: 8 }, }); expect(first).toEqual(expect.objectContaining({ accepted: true, fragmentPending: true, messageId: 'MSG-LONG-1', receivedSegments: 1, })); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); const second = await service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: '第二片正文', sequenceId: 102, remoteIp: '127.0.0.1', longMessage: { reference: 16, total: 2, index: 2, format: 8 }, }); expect(second).toEqual(expect.objectContaining({ accepted: true, messageId: 'MSG-LONG-1', messageRecordId: 'record-1', })); expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(1); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ content: '【签名】第一片第二片正文', phoneTotal: 1 }), }); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ content: '【签名】第一片第二片正文', cmppSubmitSequenceId: '101', cmppSubmitGroupMessageId: 'MSG-LONG-1', }), }); expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ content: '【签名】第一片第二片正文', phoneCount: 1, })); expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({ where: { id: 'long-group-1' }, data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }), }); }); it('accepts out-of-order and duplicate CMPP long-message fragments but rejects conflicting duplicates', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); const segments: Array> = []; const group = { id: 'long-group-2', tenantId: 'tenant-1', applicationId: 'app-1', groupKey: 'group-key-2', account: '100001', srcId: null, phoneNumbers: ['13800000001'], concatReference: 17, segmentTotal: 2, msgFmt: 8, messageId: 'MSG-LONG-2', status: 'collecting', response: null, expiresAt: new Date(Date.now() + 300_000), createdAt: new Date(), segments, }; prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve( segments.length ? { ...group, segments: [...segments] } : null, )); prisma.cmppInboundLongMessage.create.mockResolvedValue(group); prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => { const segment = { id: `segment-${data.segmentIndex}`, ...data }; segments.push(segment); return Promise.resolve(segment); }); prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve( [...segments].sort((a, b) => a.segmentIndex - b.segmentIndex), )); prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => { Object.assign(group, data); return Promise.resolve({ ...group }); }); const secondFragment = { account: '100001', phoneNumber: '13800000001', content: '第二片正文', sequenceId: 202, remoteIp: '127.0.0.1', longMessage: { reference: 17, total: 2, index: 2, format: 8 }, }; await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({ fragmentPending: true, receivedSegments: 1, })); await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({ fragmentPending: true, receivedSegments: 1, })); expect(prisma.cmppInboundLongMessageSegment.create).toHaveBeenCalledTimes(1); await expect(service.submitInboundMessage({ ...secondFragment, content: '冲突的第二片', })).rejects.toThrow('fragment 2 conflicts'); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: '【签名】第一片', sequenceId: 201, remoteIp: '127.0.0.1', longMessage: { reference: 17, total: 2, index: 1, format: 8 }, })).resolves.toEqual(expect.objectContaining({ accepted: true, messageId: 'MSG-LONG-2', messageRecordId: 'record-1', })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ content: '【签名】第一片第二片正文', cmppSubmitSequenceId: '201', }), }); }); it('resumes a persistently complete CMPP long message after processing is interrupted by a restart', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsMessageRecord.findMany.mockResolvedValue([]); const staleAt = new Date(Date.now() - 60_000); const group = { id: 'long-group-restart', tenantId: 'tenant-1', applicationId: 'app-1', groupKey: 'group-key-restart', account: '100001', srcId: null, phoneNumbers: ['13800000001'], concatReference: 18, segmentTotal: 2, msgFmt: 8, messageId: 'MSG-LONG-RESTART', status: 'processing', response: null, expiresAt: new Date(Date.now() + 300_000), createdAt: staleAt, updatedAt: staleAt, segments: [ { id: 'segment-restart-1', groupId: 'long-group-restart', segmentIndex: 1, sequenceId: '301', content: '【签名】第一片', contentHash: createHash('sha256').update('【签名】第一片').digest('hex'), }, { id: 'segment-restart-2', groupId: 'long-group-restart', segmentIndex: 2, sequenceId: '302', content: '第二片正文', contentHash: createHash('sha256').update('第二片正文').digest('hex'), }, ], }; prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group); prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => { Object.assign(group, data, { updatedAt: new Date() }); return Promise.resolve({ ...group }); }); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: '第二片正文', sequenceId: 302, remoteIp: '127.0.0.1', longMessage: { reference: 18, total: 2, index: 2, format: 8 }, })).resolves.toEqual(expect.objectContaining({ accepted: true, messageId: 'MSG-LONG-RESTART', messageRecordId: 'record-1', })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ content: '【签名】第一片第二片正文', cmppSubmitSequenceId: '301', cmppSubmitGroupMessageId: 'MSG-LONG-RESTART', }), }); expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({ where: { id: 'long-group-restart' }, data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }), }); }); it('recovers the persisted SMS result after a restart without creating a duplicate main record', async () => { const { service, prisma } = createService(); const staleAt = new Date(Date.now() - 60_000); const group = { id: 'long-group-after-record', tenantId: 'tenant-1', applicationId: 'app-1', groupKey: 'group-key-after-record', account: '100001', srcId: null, phoneNumbers: ['13800000001'], concatReference: 19, segmentTotal: 2, msgFmt: 8, messageId: 'MSG-LONG-AFTER-RECORD', status: 'processing', response: null, expiresAt: new Date(Date.now() + 300_000), createdAt: staleAt, updatedAt: staleAt, segments: [ { id: 'segment-after-record-1', groupId: 'long-group-after-record', segmentIndex: 1, sequenceId: '401', content: '【签名】第一片', contentHash: createHash('sha256').update('【签名】第一片').digest('hex'), }, { id: 'segment-after-record-2', groupId: 'long-group-after-record', segmentIndex: 2, sequenceId: '402', content: '第二片正文', contentHash: createHash('sha256').update('第二片正文').digest('hex'), }, ], }; prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group); prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => { Object.assign(group, data, { updatedAt: new Date() }); return Promise.resolve({ ...group }); }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'persisted-record-1', tenantId: 'tenant-1', applicationId: 'app-1', batchTaskId: 'persisted-task-1', messageId: 'MSG-LONG-AFTER-RECORD', phoneNumber: '13800000001', status: 'failed', errorCode: 'SIGNATURE', }]); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: '第二片正文', sequenceId: 402, remoteIp: '127.0.0.1', longMessage: { reference: 19, total: 2, index: 2, format: 8 }, })).resolves.toEqual(expect.objectContaining({ accepted: true, messageId: 'MSG-LONG-AFTER-RECORD', messageRecordId: 'persisted-record-1', taskId: 'persisted-task-1', })); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({ where: { id: 'long-group-after-record' }, data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }), }); }); it('expires incomplete or interrupted CMPP long-message groups without creating SMS records', async () => { const { service, prisma } = createService(); const now = new Date('2026-07-23T12:00:00.000Z'); prisma.cmppInboundLongMessage.updateMany.mockResolvedValue({ count: 2 }); await expect(service.expireInboundLongMessages(now)).resolves.toEqual({ count: 2 }); expect(prisma.cmppInboundLongMessage.updateMany).toHaveBeenCalledWith({ where: { status: { in: ['collecting', 'processing'] }, expiresAt: { lte: now }, }, data: { status: 'expired', completedAt: now, }, }); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); }); it('rejects the whole CMPP Submit synchronously while keeping per-destination audit records when the daily limit is exceeded', async () => { const { service, prisma, billing } = createService(); prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]); let taskIndex = 0; prisma.smsBatchTask.create.mockImplementation(({ data }) => Promise.resolve({ id: `task-${++taskIndex}`, ...data })); let messageIndex = 0; prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({ id: `record-${++messageIndex}`, ...data })); const result = await service.submitInboundMessage({ account: '100001', phoneNumbers: ['13800000001', '13900000002'], content: 'hello', sequenceId: 88, remoteIp: '127.0.0.1', }); expect(result).toEqual(expect.objectContaining({ accepted: false, result: 8, phoneCount: 2 })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'rejected', errorCode: 'DAILY_LIMIT' }), }); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); expect(billing.freeze).not.toHaveBeenCalled(); }); it('returns a failure receipt for an invalid destination while other CMPP destinations continue', async () => { const { service, prisma } = createService(); let messageIndex = 0; prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({ id: `record-${++messageIndex}`, ...data, })); await expect(service.submitInboundMessage({ account: '100001', phoneNumbers: ['13800000001', 'invalid'], content: 'hello', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-2' }, data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered', errorCode: 'INVALID_PHONE', }), }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageRecordId: 'record-2', receiptStatus: 'undelivered', errorCode: 'INVALID_PHONE', }), }); }); it('accepts only the filled client Src_Id and snapshots the real application extension', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', cmppEnterpriseCode: 'SP0001', cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', cmppClientSrcId: '000001', secretHash: 'secret-hash', status: 'active', interfaceEnabled: true, templateMismatchMode: 'reject', customerUnitPrice: 3, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: 'hello', srcId: '000001', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ clientSrcId: '000001', applicationExtension: '0001' }), }); }); it('rejects a client Src_Id that does not match the configured fill prefix and extension', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppApplicationExtension: '0001', cmppAccessNumberFillEnabled: true, cmppAccessNumberFillPrefix: '00', cmppClientSrcId: '000001', status: 'active', interfaceEnabled: true, ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: 'hello', srcId: '0001', remoteIp: '127.0.0.1', })).rejects.toThrow('CMPP Src_Id must equal the access number assigned to this application: 000001'); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); }); it('records an unreported CMPP message and returns success before delivering the template failure receipt', async () => { const { service, prisma, riskReview } = createService(); prisma.smsTemplate.findFirst.mockResolvedValue(null); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: 'unreported content', sequenceId: 1216579149, remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ cmppSubmitSequenceId: '1216579149' }), }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }), }); expect(service['postGatewayControl']).toHaveBeenCalledWith( '/downstream/receipt', expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE', submitSequenceId: 1216579149 }), ); expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled(); }); it('matches an inbound CMPP message against configured template variables and passes extracted values to risk review', async () => { const { service, prisma, riskReview } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findFirst.mockResolvedValue(null); prisma.smsTemplate.findMany.mockResolvedValue([{ id: 'tpl-code', tenantId: 'tenant-1', applicationId: 'app-1', content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。', auditStatus: 'approved', signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' }, }]); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '18821203795', content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1', content: { contains: '${' }, auditStatus: 'approved', signature: { auditStatus: 'approved' }, }, include: { signature: true }, orderBy: { updatedAt: 'desc' }, }); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ templateId: 'tpl-code', status: 'validating' }), }); expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ templateId: 'tpl-code', variables: { code: '715021' }, })); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); it('queues template-mismatched CMPP content when the application uses direct send', async () => { const { service, prisma, riskReview } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, templateMismatchMode: 'direct_send', customerUnitPrice: 3, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); prisma.smsTemplate.findFirst.mockResolvedValue(null); prisma.smsTemplate.findMany.mockResolvedValue([]); prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting', }); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '18821203795', content: '【航天信息信诺网】未配置模板但允许直接发送', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved' }, orderBy: { updatedAt: 'desc' }, }); expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ applicationId: 'app-1', content: '【航天信息信诺网】未配置模板但允许直接发送', })); expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.not.objectContaining({ templateId: expect.any(String) })); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, data: { status: 'queued', signatureId: 'sig-1' }, }); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); it('aggregates template-mismatched CMPP messages only when the application uses manual review', async () => { const { service, prisma, riskReview } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, templateMismatchMode: 'manual_review', customerUnitPrice: 3, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); prisma.smsTemplate.findFirst.mockResolvedValue(null); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: '【签名】未匹配模板的内容', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith(expect.objectContaining({ applicationId: 'app-1', account: '100001', messageRecordId: 'record-1', signatureId: 'sig-1', })); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: expect.objectContaining({ status: 'pending_review', riskTaskId: 'review-task-1', auditStatus: 'pending' }), }); expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ where: { applicationId: 'app-1', name: '【签名】', auditStatus: 'approved', }, orderBy: { updatedAt: 'desc' }, }); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); it('accepts an approved bracketed signature when only part of its channels are reported', async () => { const { service, prisma, riskReview } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, templateMismatchMode: 'manual_review', customerUnitPrice: 3, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); prisma.smsTemplate.findFirst.mockResolvedValue(null); prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting', }); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '18821203795', content: '【航天信息信诺网】您本次操作的验证码是171102,有效时10分钟。', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved', }, orderBy: { updatedAt: 'desc' }, }); expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith(expect.objectContaining({ signatureId: 'sig-1' })); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); it('fans an approved aggregated review task back into each internal CMPP batch', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1', }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', tenantId: 'tenant-1', applicationId: 'app-1', batchTaskId: 'task-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, batchTask: { id: 'task-1', sourceType: 'cmpp' }, }]); await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({ reviewTaskId: 'review-task-1', decision: 'approved', affected: 1, }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, data: { status: 'queued', errorCode: null, errorMessage: null }, }); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); it('previews imported phone files with duplicate, invalid, blacklist, and variable errors', async () => { const { service, prisma } = createService(); prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000003' }]); await expect( service.previewImport({ tenantId: 'tenant-1', applicationId: 'app-1', content: 'phoneNumber,code\n13800000001,1234\n13800000001,1234\nbad,1234\n13800000003,1234\n13900000001,', requiredVariables: ['code'], }), ).resolves.toEqual( expect.objectContaining({ totalRows: 5, validCount: 1, errorCount: 4, phones: ['13800000001'], errors: expect.arrayContaining([ expect.objectContaining({ reason: '重复号码' }), expect.objectContaining({ reason: '手机号格式非法' }), expect.objectContaining({ reason: '命中黑名单' }), expect.objectContaining({ reason: '变量列缺失:code' }), ]), }), ); expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', applicationId: 'app-1', status: 'active' }, select: { phoneNumber: true }, }); }); it('adds queued message jobs for a batch task', async () => { const { service, prisma } = createService(); const add = jest.fn().mockResolvedValue(undefined); service['getSendQueue'] = jest.fn().mockReturnValue({ add }); await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 1 }); expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-1' }, { jobId: 'record-1', attempts: 3, priority: 100 }); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } }); }); it('adds priority message jobs ahead of normal message jobs', async () => { const { service, prisma } = createService(); const add = jest.fn().mockResolvedValue(undefined); prisma.smsMessageRecord.findMany.mockResolvedValue([ { id: 'record-priority', batchTaskId: 'task-1', queuePriority: 'priority' }, { id: 'record-normal', batchTaskId: 'task-1', queuePriority: 'normal' }, ]); service['getSendQueue'] = jest.fn().mockReturnValue({ add }); await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 2 }); expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-priority' }, { jobId: 'record-priority', attempts: 3, priority: 1 }); expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-normal' }, { jobId: 'record-normal', attempts: 3, priority: 100 }); }); it('routes queued messages to gateway submit commands', async () => { const { service, prisma } = createService(); const gatewayAdd = jest.fn().mockResolvedValue(undefined); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( expect.objectContaining({ submitted: true, messageRecordId: 'record-1', channelId: 'channel-1' }), ); expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1', channelGroupId: 'group-1', channelGroupName: '默认通道组', submitStatus: 'queued', }), }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, data: expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', province: '山东', status: 'submit_queued' }), }); expect(gatewayAdd).toHaveBeenCalledWith( 'submit-command', expect.objectContaining({ schemaVersion: 'v1', messageType: 'SubmitCommand', messageId: 'MSG-1', channelId: 'channel-1', queuePriority: 'normal', phoneNumber: '13800000001', route: expect.objectContaining({ channelCode: 'CMPP-A', rateLimitPerSecond: 100 }), cmpp: expect.objectContaining({ serviceId: 'SMS', srcId: '10690000' }), upstream: expect.objectContaining({ gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'cmpp-account' }), }), ); expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'MSG-1' })); expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything()); }); it('appends the real application extension to the upstream channel base number', async () => { const { service, prisma } = createService(); const queuedMessage = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } }); prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...queuedMessage, applicationExtension: '0001', clientSrcId: '000001', }); const gatewayAdd = jest.fn().mockResolvedValue(undefined); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); await service.processSendJob({ messageRecordId: 'record-1' }); expect(gatewayAdd).toHaveBeenCalledWith( 'submit-command', expect.objectContaining({ cmpp: expect.objectContaining({ srcId: '106900000001' }) }), ); }); it('routes a partially reported signature only through its approved backup channel', async () => { const { service, prisma } = createService(); const baseRoute = await prisma.channelRouteRule.findFirst(); const primary = baseRoute.group.items[0].channel; const backup = { ...primary, id: 'channel-backup', code: 'CMPP-B' }; prisma.channelRouteRule.findFirst.mockResolvedValue({ ...baseRoute, group: { ...baseRoute.group, items: [ { ...baseRoute.group.items[0], channelId: primary.id, priority: 1, channel: primary }, { ...baseRoute.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup }, ] }, }); prisma.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: backup.id }]); const gatewayAdd = jest.fn().mockResolvedValue(undefined); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id })); expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ reportType: 'signature' }) })); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) })); }); it('persists identified carrier and province before a route lookup fails', async () => { const { service, prisma } = createService(); prisma.channelRouteRule.findFirst.mockResolvedValueOnce(null); await expect(service['selectChannelForMessage']({ id: 'record-1', tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13800000001', })).rejects.toThrow('企业应用未配置对应运营商通道组'); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, data: { carrier: 'mobile', province: '山东' }, }); }); it('reuses persisted carrier and province without querying routing dictionaries again', async () => { const { service, prisma } = createService(); service['identifyCarrier'] = jest.fn(); service['identifyProvince'] = jest.fn(); await expect(service['selectChannelForMessage']({ id: 'record-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', phoneNumber: '13800000001', carrier: 'mobile', province: '山东', })).resolves.toEqual(expect.objectContaining({ carrier: 'mobile', province: '山东' })); expect(service['identifyCarrier']).not.toHaveBeenCalled(); expect(service['identifyProvince']).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled(); }); it('updates submit result status, charges billing, and task progress', async () => { const { service, prisma, billing } = createService(); await service.handleSubmitResult({ messageId: 'MSG-1', channelId: 'channel-1', submitId: 'SUB-1', sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted', submittedAt: '2026-07-01T10:00:00.000Z', segments: [ { segmentTotal: 2, segmentIndex: 1, sequenceId: 7, gatewayMessageId: 'GW-1-A', submitStatus: 'accepted', submittedAt: '2026-07-01T10:00:00.000Z', }, { segmentTotal: 2, segmentIndex: 2, sequenceId: 8, gatewayMessageId: 'GW-1-B', submitStatus: 'accepted', submittedAt: '2026-07-01T10:00:01.000Z', }, ], }); expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'submit-1' }, data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }), }); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), }); expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' })); expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' })); expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }), }); expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledTimes(2); expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { messageRecordId_submitId_segmentIndex: { messageRecordId: 'record-1', submitId: 'SUB-1', segmentIndex: 1 } }, create: expect.objectContaining({ segmentTotal: 2, segmentIndex: 1, gatewayMessageId: 'GW-1-A', submitStatus: 'accepted' }), })); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ where: { status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }], }, data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }), }); }); it('updates admin channel test message status without business retry routing', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findFirst.mockResolvedValueOnce({ id: 'record-1', tenantId: null, batchTaskId: null, applicationId: null, templateId: null, messageId: 'MSG-1', phoneNumber: '13800000001', content: 'hello', billingUnits: 1, unitPrice: 0, amountCents: 0, status: 'submit_queued', queuePriority: 'normal', submitId: 'SUB-1', gatewayMessageId: null, channelId: 'channel-1', }); await service.handleSubmitResult({ messageId: 'MSG-1', channelId: 'channel-1', submitId: 'SUB-1', sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'timeout', errorCode: 'SUBMIT_TIMEOUT', errorMessage: 'context deadline exceeded', submittedAt: '2026-07-09T03:44:15.445Z', }); expect(prisma.channelRouteRule.findFirst).not.toHaveBeenCalled(); expect(billing.release).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.update).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'record-1', status: { not: 'delivered' } }, data: expect.objectContaining({ gatewayMessageId: 'GW-1', submitStatus: 'timeout', status: 'timeout', errorCode: 'SUBMIT_TIMEOUT', errorMessage: 'context deadline exceeded', }), }); }); it('rejects a legacy aggregate SubmitResult when it cannot match one submit attempt uniquely', async () => { const { service, prisma } = createService(); prisma.smsSubmitRecord.findMany.mockResolvedValue([ { id: 'submit-1', submitId: 'SUB-1', channelId: 'channel-1', gatewayMessageId: null }, { id: 'submit-2', submitId: 'SUB-2', channelId: 'channel-1', gatewayMessageId: null }, ]); await expect(service.handleSubmitResult({ messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: 'GW-LEGACY', submitStatus: 'accepted', })).rejects.toThrow('cannot be matched uniquely'); expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.updateMany).not.toHaveBeenCalled(); }); it('retries a direct-signature CMPP message through the next approved channel', async () => { const { service, prisma } = createService(); const route = await prisma.channelRouteRule.findFirst(); const primary = route.group.items[0].channel; const backup = { ...primary, id: 'channel-backup', code: 'CMPP-B' }; prisma.channelRouteRule.findFirst.mockResolvedValue({ ...route, group: { ...route.group, items: [ { ...route.group.items[0], channelId: primary.id, priority: 1, channel: primary }, { ...route.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup }, ], }, }); prisma.smsSubmitRecord.findMany.mockResolvedValue([ { id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: primary.id, createdAt: new Date() }, ]); const submitMessageToGateway = jest.spyOn(service as any, 'submitMessageToGateway') .mockResolvedValue({ submitted: true, messageRecordId: 'record-1', channelId: backup.id, attempt: 1 }); await expect((service as any).retryMessageIfAllowed({ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', templateId: null, signatureId: 'sig-direct', messageId: 'MSG-DIRECT-SIGNATURE', phoneNumber: '13800000001', content: '【签名】无模板内容', billingUnits: 1, queuedAt: new Date(), }, '回执失败补发')).resolves.toEqual(expect.objectContaining({ channelId: backup.id })); expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ signatureId: 'sig-direct' }), })); expect(submitMessageToGateway).toHaveBeenCalledWith( expect.objectContaining({ signatureId: 'sig-direct' }), expect.objectContaining({ channel: expect.objectContaining({ id: backup.id }) }), 1, 'submit-1', ); }); it('allows only one retry submit when three long-message failure receipts race', async () => { const { service, prisma } = createService(); const channel = await prisma.smsChannel.findUnique(); const queueAdd = jest.fn().mockResolvedValue(undefined); jest.spyOn(service as any, 'getGatewayQueue').mockReturnValue({ add: queueAdd }); jest.spyOn(service as any, 'waitForChannelRateLimit').mockResolvedValue(undefined); let claimedRetry: Record | null = null; prisma.smsSubmitRecord.create.mockImplementation(async ({ data }) => { if (data.retryOfSubmitRecordId) { if (claimedRetry) { throw new Prisma.PrismaClientKnownRequestError('duplicate retry claim', { code: 'P2002', clientVersion: '7.9.0', meta: { target: ['retryOfSubmitRecordId'] }, }); } claimedRetry = { id: 'retry-submit-1', ...data }; return claimedRetry; } return { id: 'submit-1', ...data }; }); prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve( where.retryOfSubmitRecordId ? claimedRetry : null, )); const message = { id: 'record-long-race', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', templateId: 'tpl-1', messageId: 'MSG-LONG-RACE', phoneNumber: '13800000001', content: '长短信'.repeat(136), billingUnits: 3, template: { signature: { id: 'sig-1', name: '签名' } }, }; const routed = { channel, groupId: 'group-1', carrier: 'mobile', province: '山东', routeScope: 'national', }; const results = await Promise.all([ (service as any).submitMessageToGateway(message, routed, 1, 'source-submit-1'), (service as any).submitMessageToGateway(message, routed, 1, 'source-submit-1'), (service as any).submitMessageToGateway(message, routed, 1, 'source-submit-1'), ]); expect(results.filter((result) => result.submitted)).toHaveLength(1); expect(results.filter((result) => result.duplicateRetry)).toHaveLength(2); expect(queueAdd).toHaveBeenCalledTimes(1); expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1); expect(prisma.smsMessageRecord.update).toHaveBeenCalledTimes(1); }); it('releases reservation for rejected submit result and refunds failed receipts', async () => { const { service, prisma, billing } = createService(); prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }); prisma.channelRouteRule.findFirst.mockResolvedValue({ id: 'route-1', tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }, }); await service.handleSubmitResult({ messageId: 'MSG-1', channelId: 'channel-1', submitId: 'SUB-1', gatewayMessageId: 'GW-1', submitStatus: 'rejected', }); expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') })); await service.handleReceipt({ messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV', }); expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: 'sms-refund:MSG-1', remark: '最终失败退款', })); }); it('stops failed receipt retry after the configured minute limit', async () => { const { service, prisma, billing } = createService(); prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }); prisma.smsMessageRecord.findFirst.mockResolvedValue({ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', templateId: 'tpl-1', messageId: 'MSG-1', phoneNumber: '13800000001', content: 'hello', channelId: 'channel-1', gatewayMessageId: 'GW-1', status: 'submitted', amountCents: 3, billingUnits: 1, unitPrice: 3, queuedAt: new Date(Date.now() - 90 * 60_000), }); prisma.channelRouteRule.findFirst.mockResolvedValue({ id: 'route-1', tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 2, retryTimeLimitMinutes: 75, items: [] }, }); await service.handleReceipt({ messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV', }); expect(prisma.smsSubmitRecord.create).not.toHaveBeenCalled(); expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered' }), }); }); it('does not let stale failed receipts overwrite a later delivered message', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findFirst.mockResolvedValue({ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', templateId: 'tpl-1', messageId: 'MSG-1', phoneNumber: '13800000001', content: 'hello', channelId: 'channel-new', gatewayMessageId: 'GW-NEW', status: 'delivered', amountCents: 3, billingUnits: 1, unitPrice: 3, }); await service.handleReceipt({ messageId: 'MSG-1', channelId: 'channel-old', gatewayMessageId: 'GW-OLD', receiptStatus: 'undelivered', rawStatus: 'UNDELIV', }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-old', gatewayMessageId: 'GW-OLD', receiptStatus: 'undelivered' }), }); expect(billing.refund).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith({ where: { id: 'record-1' }, data: expect.objectContaining({ status: 'failed' }), }); }); it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsSubmitRecord.findMany .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'submit-timeout-1', channelId: 'channel-1', gatewayMessageId: null, submitStatus: 'timeout', submittedAt: new Date('2026-07-01T10:00:00.000Z'), messageRecord: { id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', channelId: 'channel-1', gatewayMessageId: null, status: 'timeout', }, }, ]); await service.handleReceipt({ messageId: 'receipt-123456789', channelId: 'channel-1', sequenceId: 7, gatewayMessageId: 'GW-RECOVERED-1', phoneNumber: '13800000001', receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: '2026-07-01T10:01:00.000Z', }); expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'submit-timeout-1', gatewayMessageId: null, }, data: { gatewayMessageId: 'GW-RECOVERED-1', sequenceId: 7, }, }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageRecordId: 'record-1', messageId: 'MSG-1', gatewayMessageId: 'GW-RECOVERED-1', }), }); }); it('matches identical upstream Msg_Id values by channel and destination instead of another channel record', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([ { id: 'submit-channel-b', channelId: 'channel-b', gatewayMessageId: 'SHARED-UPSTREAM-ID', messageRecord: { id: 'record-channel-b', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-B', phoneNumber: '15601992925', channelId: 'channel-b', gatewayMessageId: 'SHARED-UPSTREAM-ID', status: 'submitted', }, }, ]); await service.handleReceipt({ messageId: 'receipt-SHARED-UPSTREAM-ID', channelId: 'channel-b', gatewayMessageId: 'SHARED-UPSTREAM-ID', phoneNumber: '15601992925', receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: '2026-07-01T10:01:00.000Z', }); expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ channelId: 'channel-b', gatewayMessageId: 'SHARED-UPSTREAM-ID', messageRecord: { phoneNumber: '15601992925' }, }), })); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-channel-b' }, data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered', channelId: 'channel-b', gatewayMessageId: 'SHARED-UPSTREAM-ID', receiptRawStatus: 'DELIVRD', }), }); }); it('matches a receipt from another connection only when it is the unique channel of the same supplier', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-copy', account: 'C59748', gatewayHost: 'supplier.example.com', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0', }); prisma.smsSubmitRecord.findMany .mockResolvedValueOnce([]); prisma.smsMessageSegmentAudit.findMany .mockResolvedValueOnce([ { id: 'segment-2', submitId: 'SUB-LONG-1', submitRecordId: 'submit-original', channelId: 'channel-original', gatewayMessageId: '736070230367350788', submitRecord: { id: 'submit-original', submitId: 'SUB-LONG-1' }, channel: { id: 'channel-original', account: 'C59748', gatewayHost: 'supplier.example.com', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0', }, messageRecord: { id: 'record-long', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-LONG-1', submitId: 'SUB-LONG-1', phoneNumber: '13127620092', channelId: 'channel-original', gatewayMessageId: '736070227905294338', status: 'submitted', billingUnits: 2, }, }, ]) .mockResolvedValueOnce([]); await service.handleReceipt({ messageId: 'receipt-736070230367350788', channelId: 'channel-copy', gatewayMessageId: '736070230367350788', phoneNumber: '13127620092', receiptStatus: 'delivered', rawStatus: 'DELIVRD', }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-original', messageRecordId: 'record-long', messageId: 'MSG-LONG-1', }), }); }); it('does not match the same Msg_Id across channels belonging to different suppliers', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-other', account: 'OTHER', gatewayHost: 'other.example.com', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0', }); prisma.smsSubmitRecord.findMany .mockResolvedValueOnce([]) .mockResolvedValueOnce([]); prisma.smsMessageSegmentAudit.findMany.mockResolvedValueOnce([ { id: 'segment-original', submitRecordId: 'submit-original', submitId: 'SUB-ORIGINAL', channelId: 'channel-original', gatewayMessageId: 'SHARED-ID', submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' }, channel: { id: 'channel-original', account: 'C59748', gatewayHost: 'supplier.example.com', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0', }, messageRecord: { id: 'record-original', messageId: 'MSG-ORIGINAL', phoneNumber: '13127620092', }, }, ]); await expect(service.handleReceipt({ messageId: 'receipt-SHARED-ID', channelId: 'channel-other', gatewayMessageId: 'SHARED-ID', phoneNumber: '13127620092', receiptStatus: 'delivered', rawStatus: 'DELIVRD', })).rejects.toThrow('SMS message record not found'); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); it('waits for every long-message segment before marking the main message delivered', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue({ id: 'record-long', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-LONG-1', submitId: 'SUB-LONG-1', phoneNumber: '13127620092', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-1', status: 'submitted', billingUnits: 2, }); prisma.smsSubmitRecord.findFirst.mockResolvedValue({ id: 'submit-long', submitId: 'SUB-LONG-1', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-1', }); prisma.smsMessageSegmentAudit.findMany .mockResolvedValueOnce([ { segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null }, ]) .mockResolvedValueOnce([ { segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, ]); await service.handleReceipt({ messageId: 'MSG-LONG-1', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-1', phoneNumber: '13127620092', receiptStatus: 'delivered', rawStatus: 'DELIVRD', }); expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'delivered' }), })); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); prisma.smsReceiptRecord.findUnique.mockResolvedValue(null); await service.handleReceipt({ messageId: 'MSG-LONG-1', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-2', phoneNumber: '13127620092', receiptStatus: 'delivered', rawStatus: 'DELIVRD', }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'record-long' }, data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), })); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1); }); it('creates and sends only one downstream final receipt under concurrent completion', async () => { const { service, prisma } = createService(); let claimedDelivery: Record | null = null; prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => { if (claimedDelivery) { throw new Prisma.PrismaClientKnownRequestError('duplicate downstream receipt', { code: 'P2002', clientVersion: '7.9.0', meta: { target: ['dedupeKey'] }, }); } claimedDelivery = { id: 'delivery-once', ...data, createdAt: new Date(), updatedAt: new Date(), }; return claimedDelivery; }); prisma.cmppDownstreamDelivery.findUnique.mockImplementation(({ where }) => Promise.resolve( where.dedupeKey || where.id === 'delivery-once' ? claimedDelivery : null, )); const payload = { tenantId: 'tenant-1', applicationId: 'app-1', messageRecordId: 'record-long-race', messageId: 'MSG-LONG-RACE', deliveryType: 'receipt' as const, payload: { messageId: 'MSG-LONG-RACE', receiptStatus: 'undelivered', rawStatus: 'FLNIGLK', }, }; const results = await Promise.all([ (service as any).queueAndTryDownstreamDelivery(payload), (service as any).queueAndTryDownstreamDelivery(payload), (service as any).queueAndTryDownstreamDelivery(payload), ]); expect(results.map((result) => result.id)).toEqual([ 'delivery-once', 'delivery-once', 'delivery-once', ]); expect(service['postGatewayControl']).toHaveBeenCalledTimes(1); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(3); }); it('marks a long message failed when a non-primary segment returns an explicit failure', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue({ id: 'record-long', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-LONG-FAIL', submitId: 'SUB-LONG-FAIL', phoneNumber: '18821203795', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-1', status: 'submitted', billingUnits: 2, amountCents: 6, unitPrice: 3, }); prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({ id: 'segment-2', messageRecordId: 'record-long', submitRecordId: 'submit-long', submitId: 'SUB-LONG-FAIL', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-2', segmentIndex: 2, segmentTotal: 2, }); prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([ { segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() }, ]); prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve( where.id === 'submit-long' ? { id: 'submit-long', messageRecordId: 'record-long', channelId: 'channel-1', submitId: 'SUB-LONG-FAIL', submitStatus: 'accepted', } : null, )); prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' }); prisma.channelRouteRule.findFirst.mockResolvedValue({ id: 'route-1', tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [], }, }); await service.handleReceipt({ messageId: 'MSG-LONG-FAIL', channelId: 'channel-1', gatewayMessageId: 'GW-SEG-2', phoneNumber: '18821203795', receiptStatus: 'undelivered', rawStatus: 'YL:1014', }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-long' }, data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'YL:1014', }), }); expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending', }), }); }); it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => { const { service, prisma } = createService(); prisma.smsReceiptRecord.findUnique .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'receipt-existing', messageRecordId: 'record-1', messageRecord: { id: 'record-1', messageId: 'MSG-1', status: 'delivered' }, }); const receipt = { messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: 'GW-1', phoneNumber: '13800000001', receiptStatus: 'delivered' as const, rawStatus: 'DELIVRD', deliveredAt: '2026-07-01T10:01:00.000Z', }; await service.handleReceipt(receipt); await service.handleReceipt(receipt); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(1); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1); }); it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsSubmitRecord.findMany .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'submit-timeout-1', messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' }, }, { id: 'submit-timeout-2', messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' }, }, ]); await expect( service.handleReceipt({ messageId: 'receipt-ambiguous', channelId: 'channel-1', gatewayMessageId: 'GW-AMBIGUOUS', phoneNumber: '13800000001', receiptStatus: 'delivered', rawStatus: 'DELIVRD', }), ).rejects.toThrow('SMS message record not found'); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); it('blocks submit when signature is not approved on the selected channel', async () => { const { service, prisma } = createService(); const gatewayAdd = jest.fn().mockResolvedValue(undefined); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); prisma.channelSignatureReportTask.findMany.mockResolvedValue([]); prisma.smsMessageRecord.findUnique.mockResolvedValue({ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', templateId: 'tpl-1', messageId: 'MSG-1', phoneNumber: '13800000001', content: 'hello', billingUnits: 1, unitPrice: 3, amountCents: 3, status: 'queued', queuePriority: 'normal', batchTask: { sourceType: 'cmpp' }, template: { signature: { id: 'sig-1', name: '签名' } }, }); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( expect.objectContaining({ submitted: false, status: 'failed', reason: '无已报备通过且在线的可用通道' }), ); expect(gatewayAdd).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ receiptStatus: 'undelivered', errorCode: 'ROUTE' }), }); }); it('only selects channel group items allocated to the matched carrier', async () => { const { service, prisma } = createService(); const gatewayAdd = jest.fn().mockResolvedValue(undefined); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); prisma.channelRouteRule.findFirst.mockResolvedValue({ id: 'route-1', tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [ { id: 'wrong-item', groupId: 'group-1', channelId: 'channel-unicom', carrier: 'unicom', priority: 1, province: null, channel: { id: 'channel-unicom', code: 'CMPP-U', account: 'u', srcId: '1061', rateLimitPerSecond: 100, unitPrice: 3, status: 'active', carrier: 'all', sendRegion: '全国', connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }], }, }, { id: 'mobile-item', groupId: 'group-1', channelId: 'channel-all', carrier: 'mobile', priority: 2, province: null, channel: { id: 'channel-all', code: 'CMPP-ALL', account: 'all', srcId: '1062', rateLimitPerSecond: 100, unitPrice: 3, status: 'active', carrier: 'all', sendRegion: '全国', connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }], }, }, ], }, }); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( expect.objectContaining({ channelId: 'channel-all' }), ); expect(gatewayAdd).toHaveBeenCalledWith( 'submit-command', expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }), ); }); it('records receipts and uplink messages from gateway events', async () => { const { service, prisma } = createService(); await service.handleReceipt({ messageId: 'MSG-1', channelId: 'channel-1', sequenceId: 7, gatewayMessageId: 'GW-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: '2026-07-01T10:01:00.000Z', }); await service.handleUplink({ messageId: 'MSG-1', channelId: 'channel-1', sequenceId: 8, phoneNumber: '13800000001', destId: '10690000', content: 'TD', receivedAt: '2026-07-01T10:02:00.000Z', }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }), }); expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', content: 'TD' }), }); }); it('records ambiguous uplink match candidates for shared access numbers', async () => { const { service, prisma } = createService(); prisma.channelRouteRule.findMany.mockResolvedValue([ { applicationId: 'app-1' }, { applicationId: 'app-2' }, ]); prisma.smsApplication.findMany.mockResolvedValue([ { id: 'app-1', tenantId: 'tenant-1', name: '应用A' }, { id: 'app-2', tenantId: 'tenant-2', name: '应用B' }, ]); await service.handleUplink({ channelId: 'channel-1', sequenceId: 8, phoneNumber: '13800000001', destId: '10690000', content: '回复TD', receivedAt: '2026-07-01T10:02:00.000Z', }); expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: undefined, applicationId: undefined, matchStatus: 'ambiguous', matchReason: '接入号匹配多个应用', }), }); expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({ data: [ expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', matchSource: 'access_number', confidence: 70 }), expect.objectContaining({ tenantId: 'tenant-2', applicationId: 'app-2', matchSource: 'access_number', confidence: 70 }), ], skipDuplicates: true, }); }); it('claims an ambiguous uplink candidate and queues downstream uplink delivery', async () => { const { service, prisma } = createService(); service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true }); await service.claimUplinkMatchCandidate('uplink-1', 'candidate-1', 'admin-1'); expect(prisma.smsUplinkMessage.update).toHaveBeenCalledWith({ where: { id: 'uplink-1' }, data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', messageRecordId: 'record-1', messageId: 'MSG-1', matchStatus: 'matched', }), }); expect(prisma.smsUplinkMatchCandidate.updateMany).toHaveBeenCalledWith({ where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' }, data: { status: 'rejected' }, }); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', messageRecordId: 'record-1', messageId: 'MSG-1', deliveryType: 'uplink', status: 'pending', }), }); }); it('records gateway submit exceptions and safely allows manual requeue', async () => { const { service, prisma } = createService(); service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0'); await service.recordGatewaySubmitDeadLetter({ streamMessageId: '1710000000000-0', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', traceId: 'trace-1', messageId: 'MSG-1', submitId: 'SUB-1', failureCode: 'SUBMIT_PROCESSING_FAILED', failureMessage: 'network down', attempts: 3, maxAttempts: 3, commandPayload: { messageType: 'SubmitCommand', submitId: 'SUB-1' }, rawPayload: '{"messageType":"SubmitCommand"}', deadLetteredAt: '2026-07-08T12:00:00.000Z', }); expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith({ where: { streamMessageId: '1710000000000-0' }, update: expect.objectContaining({ submitId: 'SUB-1', failureCode: 'SUBMIT_PROCESSING_FAILED', attempts: 3, }), create: expect.objectContaining({ streamMessageId: '1710000000000-0', failureMessage: 'network down', }), }); await service.requeueGatewaySubmitDeadLetter('dead-1', { confirmedNotSubmitted: true, reason: '确认通道连接失败且运营商未收到该短信', operatorId: 'user-1', }); expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith( expect.objectContaining({ submitId: 'SUB-1' }), 'gateway:submit:requeue:dead-1:1', ); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ where: { id: 'dead-1', status: 'pending' }, data: { status: 'requeueing' }, }); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ where: { id: 'dead-1', status: 'requeueing' }, data: expect.objectContaining({ status: 'requeued', manualRetryCount: { increment: 1 }, lastRetryStreamId: '1710000001000-0', }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'gateway.submit_dead_letter_requeue', resource: 'gateway_submit_dead_letter', resourceId: 'dead-1', userId: 'user-1', detail: expect.objectContaining({ reason: '确认通道连接失败且运营商未收到该短信', confirmedNotSubmitted: true, }), }), }); }); it('blocks submit exception requeue when the upstream result may already be accepted', async () => { const { service, prisma } = createService(); prisma.smsMessageRecord.findUnique.mockResolvedValueOnce({ id: 'record-1', messageId: 'MSG-1', status: 'submitted', submitStatus: 'accepted', receiptStatus: null, }); await expect(service.requeueGatewaySubmitDeadLetter('dead-1', { confirmedNotSubmitted: true, reason: '尝试重新发送这条短信', })).rejects.toThrow('为避免重复发送,禁止重新入队'); expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled(); }); it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => { const { service, prisma } = createService(); await service.recordGatewaySubmitDeadLetter({ streamMessageId: '1710000000000-0', failureCode: 'SUBMIT_PROCESSING_FAILED', failureMessage: 'repeated report', attempts: 3, maxAttempts: 3, }); expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(expect.objectContaining({ update: expect.not.objectContaining({ status: expect.anything(), resolvedAt: expect.anything(), resolvedStatus: expect.anything() }), })); }); it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', cmppEnterpriseCode: 'SP0001', secretHash: 'secret-hash', status: 'disabling', interfaceEnabled: true, cmppMaxConnections: 2, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); await expect(service.authenticateInboundApplication({ account: '100001', password: 'secret-hash', remoteIp: '127.0.0.1', })).resolves.toEqual(expect.objectContaining({ status: 'authenticated' })); await expect(service.submitInboundMessage({ account: '100001', phoneNumber: '13800000001', content: 'hello', remoteIp: '127.0.0.1', })).rejects.toThrow('disabled for new submissions'); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); }); it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'deleted', interfaceEnabled: true, tenant: { id: 'tenant-1', status: 'deleted', certificationStatus: 'approved' }, }); prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]); await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100 })) .resolves.toEqual([]); }); it('persists each upstream SubmitResp segment before the aggregate result arrives', async () => { const { service, prisma } = createService(); await service.handleSubmitSegmentResult({ messageId: 'MSG-1', channelId: 'channel-1', submitId: 'SUB-1', segmentTotal: 3, segmentIndex: 1, sequenceId: 71, gatewayMessageId: 'GW-SEG-1', submitStatus: 'accepted', submittedAt: '2026-07-25T15:00:00.000Z', }); expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { messageRecordId_submitId_segmentIndex: { messageRecordId: 'record-1', submitId: 'SUB-1', segmentIndex: 1, }, }, create: expect.objectContaining({ segmentTotal: 3, sequenceId: 71, gatewayMessageId: 'GW-SEG-1', submitStatus: 'accepted', }), })); expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'submit-1', gatewayMessageId: null }, data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }), })); }); it('rejects a legacy segment result when multiple channel attempts could match', async () => { const { service, prisma } = createService(); prisma.smsSubmitRecord.findMany.mockResolvedValue([ { id: 'submit-2', submitId: 'SUB-2', messageRecordId: 'record-1', channelId: 'channel-1' }, { id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: 'channel-1' }, ]); await expect(service.handleSubmitSegmentResult({ messageId: 'MSG-1', channelId: 'channel-1', segmentTotal: 2, segmentIndex: 1, sequenceId: 72, gatewayMessageId: 'GW-SEG-2', submitStatus: 'accepted', })).rejects.toThrow('cannot be matched uniquely'); expect(prisma.smsMessageSegmentAudit.upsert).not.toHaveBeenCalled(); expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled(); }); it('durably intakes an upstream receipt before asynchronous business matching', async () => { const { service, prisma } = createService(); jest.spyOn(service as any, 'processUpstreamReceiptInboxRecord').mockResolvedValue(false); await expect(service.intakeReceipt({ messageId: 'receipt-9001', channelId: 'channel-1', connectionId: 'gateway-connection-2', sequenceId: 81, gatewayMessageId: '9001', phoneNumber: '13800000001', receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: '2026-07-25T15:01:00.000Z', })).resolves.toEqual(expect.objectContaining({ accepted: true, inboxId: 'receipt-inbox-1', })); expect(prisma.upstreamReceiptInbox.upsert).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ incomingChannelId: 'channel-1', incomingConnectionId: 'gateway-connection-2', upstreamAccount: 'cmpp-account', upstreamHost: '127.0.0.1', upstreamPort: 17890, protocol: 'CMPP', protocolVersion: '3.0', gatewayMessageId: '9001', status: 'pending', }), })); }); it('does not downgrade an early terminal receipt when the aggregate submit result arrives later', async () => { const { service, prisma } = createService(); const terminalMessage = { id: 'record-1', messageId: 'MSG-1', tenantId: null, batchTaskId: null, applicationId: null, channelId: 'channel-1', submitId: 'SUB-1', gatewayMessageId: 'GW-SEG-1', phoneNumber: '13800000001', billingUnits: 1, amountCents: 0, status: 'failed', }; prisma.smsMessageRecord.findFirst.mockResolvedValue(terminalMessage); prisma.smsMessageRecord.findUnique.mockResolvedValue(terminalMessage); prisma.smsMessageRecord.updateMany .mockResolvedValueOnce({ count: 0 }) .mockResolvedValueOnce({ count: 1 }); await service.handleSubmitResult({ messageId: 'MSG-1', channelId: 'channel-1', submitId: 'SUB-1', sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted', }); expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, data: expect.objectContaining({ status: 'submitted' }), })); expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(2, { where: { id: 'record-1', gatewayMessageId: null }, data: expect.objectContaining({ gatewayMessageId: 'GW-1' }), }); }); it('recovers a stale submit requeue with the same Redis idempotency key', async () => { const { service, prisma } = createService(); const stale = { ...await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } }), status: 'requeueing', updatedAt: new Date('2026-07-21T07:00:00.000Z'), }; prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]); const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0'); await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1, failed: 0 }); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, { where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt }, data: { status: 'requeue_recovering' }, }); expect(publish).toHaveBeenCalledWith( stale.commandPayload, 'gateway:submit:requeue:dead-1:1', ); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, { where: { id: 'dead-1', status: 'requeue_recovering' }, data: expect.objectContaining({ status: 'requeued', manualRetryCount: { increment: 1 }, lastRetryStreamId: '1710000001000-0', }), }); }); it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => { const { service, prisma } = createService(); service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' }); prisma.cmppDownstreamDelivery.updateMany .mockResolvedValueOnce({ count: 1 }) .mockResolvedValueOnce({ count: 0 }); const results = await Promise.allSettled([ service.requeueDownstreamDelivery('delivery-1'), service.requeueDownstreamDelivery('delivery-1'), ]); expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); expect(service['postGatewayControl']).toHaveBeenCalledTimes(1); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({ where: { id: 'delivery-1', status: 'failed', updatedAt: new Date('2026-07-21T08:00:00.000Z'), }, data: expect.objectContaining({ status: 'manual_requeueing', manualRetryCount: { increment: 1 }, }), }); }); it('recovers a stale downstream manual-requeue claim into the Gateway pending path', async () => { const { service, prisma } = createService(); const updatedAt = new Date('2026-07-21T07:00:00.000Z'); prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]); await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1 }); expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({ where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } }, select: { id: true, updatedAt: true }, orderBy: { updatedAt: 'asc' }, take: 500, }); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({ where: { id: 'delivery-1', status: 'manual_requeueing', updatedAt }, data: { status: 'pending', nextRetryAt: null, lastError: '人工重投进程中断,已恢复为待投递', }, }); }); it('records gateway downstream recovery statuses', async () => { const { service, prisma } = createService(); await expect(service.recordGatewayDownstreamRecoveryStatus({ account: '100001', gatewayInstanceId: 'gateway-a', state: 'waiting_connection', lastAttemptAt: '2026-07-08T12:00:00.000Z', nextRetryAt: '2026-07-08T12:10:00.000Z', attemptCount: 2, lockOwner: 'gateway-a', lockExpiresAt: '2026-07-08T12:00:30.000Z', lastError: 'downstream client is not connected', })).resolves.toEqual(expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection', })); expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { account: '100001' }, update: expect.objectContaining({ lockOwner: 'gateway-a', lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), failureCategory: 'client_disconnected', }), create: expect.objectContaining({ lockOwner: 'gateway-a', lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), failureCategory: 'client_disconnected', }), })); expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'gateway.downstream_recovery_status_changed', resource: 'gateway_downstream_recovery_status', }), })); }); it('does not append recovery audit logs when only periodic timestamps change', async () => { const { service, prisma } = createService(); prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({ state: 'waiting_connection', gatewayInstanceId: 'gateway-a', lockOwner: 'gateway-a', failureCategory: 'client_disconnected', lastError: 'downstream client is not connected', lastSkipReason: null, }); await service.recordGatewayDownstreamRecoveryStatus({ account: '100001', gatewayInstanceId: 'gateway-a', state: 'waiting_connection', lastAttemptAt: '2026-07-08T12:01:00.000Z', nextRetryAt: '2026-07-08T12:11:00.000Z', attemptCount: 3, lockOwner: 'gateway-a', lockExpiresAt: '2026-07-08T12:01:30.000Z', lastError: 'downstream client is not connected', }); expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled(); expect(prisma.operationLog.create).not.toHaveBeenCalled(); }); it('marks downstream delivery as failed after reaching retry limit', async () => { const { service, prisma } = createService(); const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES; process.env.CMPP_DOWNSTREAM_MAX_RETRIES = '2'; prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 1, lastError: null, }); try { await service.markDownstreamDeliveryFailed('delivery-1', 'client offline'); } finally { if (previous === undefined) { delete process.env.CMPP_DOWNSTREAM_MAX_RETRIES; } else { process.env.CMPP_DOWNSTREAM_MAX_RETRIES = previous; } } expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({ where: { id: 'delivery-1' }, data: expect.objectContaining({ status: 'failed', retryCount: 2, nextRetryAt: null, lastError: 'client offline', }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'gateway.downstream_delivery_failed', resource: 'cmpp_downstream_delivery', resourceId: 'delivery-1', }), }); }); it('immediately terminates an unrecoverable downstream delivery', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 0, retryEnabled: true, }); await service.markDownstreamDeliveryFailed( 'delivery-1', '历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id', 'unrecoverable', ); expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', retryCount: 1, nextRetryAt: null, }), })); }); it('does not let the pending timeout scan overwrite a delivery that is already awaiting acknowledgement', async () => { const { service, prisma } = createService(); const awaitingAck = { id: 'delivery-1', status: 'awaiting_ack', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 0, }; prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue(awaitingAck); await expect(service.markDownstreamDeliveryFailed( 'delivery-1', '下游投递排队超过 72 小时,系统自动终止重试', 'queue_timeout', )).resolves.toEqual(awaitingAck); expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled(); }); it('only marks downstream delivery delivered after a successful CMPP_DELIVER_RESP', async () => { const { service, prisma } = createService(); await service.markDownstreamDeliverySent({ id: 'delivery-1', connectionId: 'conn-1', sequenceId: '37', messageId: '9016479179509871733', sentAt: '2026-07-14T03:40:18.030Z', ackDeadlineAt: '2026-07-14T03:40:48.030Z', }); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: { id: 'delivery-1', status: { not: 'delivered' } }, data: expect.objectContaining({ status: 'awaiting_ack', ackSequenceId: '37', connectionId: 'conn-1' }), })); expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(expect.objectContaining({ create: expect.objectContaining({ deliveryId: 'delivery-1', attemptNo: 1, connectionId: 'conn-1', sequenceId: '37', status: 'awaiting_ack', }), })); await service.acknowledgeDownstreamDelivery({ id: 'delivery-1', connectionId: 'conn-1', sequenceId: '37', messageId: '9016479179509871733', result: 0, acknowledgedAt: '2026-07-14T03:40:18.060Z', }); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'delivered', ackResult: 0, deliveredAt: new Date('2026-07-14T03:40:18.060Z') }), })); expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(expect.objectContaining({ update: expect.objectContaining({ status: 'acknowledged', acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'), ackResult: 0, }), })); }); it('does not treat Result=0 with Msg_Id=0 as a business acknowledgement', async () => { const { service, prisma } = createService(); await service.acknowledgeDownstreamDelivery({ id: 'delivery-1', connectionId: 'conn-1', sequenceId: '91', messageId: '0', result: 0, acknowledgedAt: '2026-07-14T07:07:49.336Z', }); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }), })); expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }), })); expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'delivered' }), })); }); it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', status: 'awaiting_ack', retryEnabled: false, retryCount: 0, }); await service.markDownstreamDeliveryFailed('delivery-1', 'CMPP_DELIVER_RESP timeout', 'ack_timeout'); expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'unconfirmed', retryCount: 1, nextRetryAt: null }), })); }); it('uses exponential backoff for downstream delivery retries before final failure', async () => { const { service, prisma } = createService(); const previousBase = process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS; const previousMax = process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS; const now = Date.UTC(2026, 6, 8, 12, 0, 0); const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = '60000'; process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = '600000'; prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 2, lastError: null, }); try { await service.markDownstreamDeliveryFailed('delivery-1', 'temporary network jitter'); } finally { dateNowSpy.mockRestore(); if (previousBase === undefined) { delete process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS; } else { process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = previousBase; } if (previousMax === undefined) { delete process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS; } else { process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = previousMax; } } expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({ where: { id: 'delivery-1' }, data: expect.objectContaining({ status: 'pending', retryCount: 3, nextRetryAt: new Date(now + 240_000), lastError: 'temporary network jitter', }), }); }); it('requeues downstream delivery through real gateway control path', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', status: 'failed', retryCount: 3, manualRetryCount: 1, updatedAt: new Date('2026-07-21T08:00:00.000Z'), lastError: 'downstream client is not connected', payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' }, application: { cmppAccount: '100001' }, }); prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', status: 'pending', retryCount: 0, manualRetryCount: 2, }); service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true }); await service.requeueDownstreamDelivery('delivery-1'); expect(service['postGatewayControl']).toHaveBeenCalledWith( '/downstream/receipt', expect.objectContaining({ deliveryId: 'delivery-1', account: '100001', messageId: 'MSG-1', }), ); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'gateway.downstream_delivery_requeue', resource: 'cmpp_downstream_delivery', resourceId: 'delivery-1', }), }); expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'delivery-1', status: 'failed', updatedAt: new Date('2026-07-21T08:00:00.000Z'), }), data: expect.objectContaining({ status: 'manual_requeueing', retryCount: 0, manualRetryCount: { increment: 1 }, lastRetriedAt: expect.any(Date), acknowledgedAt: null, ackResult: null, ackMessageId: null, deliveredAt: null, }), })); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'gateway.downstream_delivery_requeue', detail: expect.objectContaining({ previousStatus: 'failed', previousRetryCount: 3, manualRetryCount: 2, lastRetriedAt: expect.any(Date), }), }), }); }); it('rejects manual requeue while downstream acknowledgement is pending', async () => { const { service, prisma } = createService(); service['postGatewayControl'] = jest.fn(); prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({ id: 'delivery-1', status: 'awaiting_ack', payload: { account: '100001' }, application: { cmppAccount: '100001' }, }); await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投'); expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled(); expect(service['postGatewayControl']).not.toHaveBeenCalled(); }); it('terminates a manual requeue when gateway reports it is unrecoverable', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', status: 'failed', retryCount: 3, manualRetryCount: 0, payload: { account: '100001', messageId: 'MSG-1', receiptStatus: 'delivered' }, application: { cmppAccount: '100001' }, }).mockResolvedValueOnce({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', status: 'pending', retryCount: 0, retryEnabled: true, }); service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: false, retryable: false, reasonCode: 'MISSING_SUBMIT_SEQUENCE_ID', errorMessage: '历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id,系统已终止重投', }); await service.requeueDownstreamDelivery('delivery-1'); expect(prisma.cmppDownstreamDelivery.update).toHaveBeenLastCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', nextRetryAt: null, lastError: expect.stringContaining('MISSING_SUBMIT_SEQUENCE_ID'), }), })); }); it('supports batch requeue of downstream deliveries', async () => { const { service } = createService(); service.requeueDownstreamDelivery = jest.fn() .mockResolvedValueOnce({ id: 'delivery-1' }) .mockRejectedValueOnce(new Error('Gateway control delivery failed')); await expect(service.batchRequeueDownstreamDeliveries(['delivery-1', 'delivery-2'])).resolves.toEqual({ total: 2, successCount: 1, failedCount: 1, results: [ { id: 'delivery-1', status: 'success' }, { id: 'delivery-2', status: 'failed', errorMessage: 'Gateway control delivery failed' }, ], }); }); it('rejects empty downstream batch requeue selection', async () => { const { service } = createService(); await expect(service.batchRequeueDownstreamDeliveries([])).rejects.toThrow('请选择至少一条下游投递记录'); }); it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findMany.mockResolvedValue([ { id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 }, { id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 }, ]); prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }) .mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-2', billingStatus: 'charged' }); await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 2 }); expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({ where: { tenantId: { not: null }, status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) }, }, select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true }, take: 10000, }); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'record-1', status: { in: ['submitted', 'unknown'] } }, data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }), }); expect(billing.refund).toHaveBeenCalledTimes(2); expect(prisma.smsBatchTask.update).toHaveBeenCalled(); }); it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]); service.markDownstreamDeliveryFailed = jest.fn().mockResolvedValue({ id: 'delivery-expired', status: 'failed' }); await expect(service.markExpiredDownstreamDeliveries(72)).resolves.toEqual({ failed: 1 }); expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { status: 'pending', OR: [ { lastRetriedAt: null, createdAt: { lte: expect.any(Date) } }, { lastRetriedAt: { lte: expect.any(Date) } }, ], }, })); expect(service.markDownstreamDeliveryFailed).toHaveBeenCalledWith( 'delivery-expired', '下游投递排队超过 72 小时,系统自动终止重试', 'queue_timeout', ); }); it('starts the automatic receipt-timeout scan after application startup', async () => { jest.useFakeTimers(); const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; const { service } = createService(); const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 }); const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 }); try { process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true'; process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false'; service.onModuleInit(); await jest.advanceTimersByTimeAsync(60_000); expect(scan).toHaveBeenCalledWith({}); expect(downstreamScan).toHaveBeenCalledWith(); await service.onModuleDestroy(); } finally { if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled; if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled; jest.useRealTimers(); } }); });