Files
lislgosms/api/src/send-chain/send-chain.service.spec.ts
T

4274 lines
168 KiB
TypeScript

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',
cmppSubmitSequenceId: '101',
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: true,
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([]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
},
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(),
},
smsReceiptAnomaly: {
upsert: jest.fn().mockResolvedValue({ id: 'receipt-anomaly-1', status: 'pending' }),
},
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 phoneFrequency = {
reserve: jest.fn().mockResolvedValue(new Map()),
};
const service = new SendChainService(
prisma as never,
billing,
riskReview,
phoneFrequency as never,
openApi as never,
);
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
return { service, prisma, billing, riskReview, phoneFrequency };
}
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('rejects only phones that hit application frequency rules and excludes them from billing', async () => {
const { service, prisma, billing, phoneFrequency } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
phoneFrequency.reserve.mockResolvedValue(new Map([
['13800000002', {
code: 'PHONE_FREQUENCY_LIMIT',
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
}],
]));
(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', '13800000002'],
});
expect(phoneFrequency.reserve).toHaveBeenCalledWith(
'tenant-1',
'app-1',
['13800000001', '13800000002'],
'client',
);
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 }));
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
expect.objectContaining({
phoneNumber: '13800000002',
status: 'submit_failed',
submitStatus: 'rejected',
errorCode: 'PHONE_FREQUENCY_LIMIT',
amountCents: 0,
}),
]),
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => {
const { service, prisma, riskReview, phoneFrequency } = createService();
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
status: 'pending_review',
reason: '命中人工审核规则',
task: { id: 'review-task-1' },
});
phoneFrequency.reserve.mockResolvedValue(new Map([
['13800000001', {
code: 'PHONE_FREQUENCY_LIMIT',
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
}],
]));
await service.createBatchTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
templateId: 'tpl-1',
content: 'hello',
phones: ['13800000001'],
});
expect(prisma.smsSendTask.update).toHaveBeenCalledWith({
where: { id: 'review-task-1' },
data: expect.objectContaining({
status: 'rejected',
riskDecision: 'block',
reviewReason: null,
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
}),
});
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'rejected',
auditStatus: 'rejected',
reviewReason: null,
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
}),
});
});
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 a new task that selects a deleted template', async () => {
const { service, prisma } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
content: 'hello', auditStatus: 'deleted',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
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('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'])('does not block a matched %s drainage URL and still preserves the matched resource', 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: 'ready', rejectReason: null }),
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined,
})],
});
expect(riskReview.evaluateTask).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 previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
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';
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
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;
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
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('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => {
const { service, prisma, billing } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
signature: { id: 'sig-1', auditStatus: 'approved' },
});
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
}]);
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(new Date())).resolves.toEqual({
dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ relatedId: 'task-1' }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('still blocks scheduled dispatch when the persisted template signature is no longer approved', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
signature: { id: 'sig-1', auditStatus: 'deleted' },
});
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
}]);
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
dispatched: 0,
results: [{ taskId: 'task-1', status: 'failed', reason: '短信签名未审核通过' }],
});
expect(billing.freeze).not.toHaveBeenCalled();
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
});
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 and audits the inbound parameters after Gateway authentication', async () => {
const { service, prisma } = createService();
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({
account: '100001',
enterpriseCode: 'SP0001',
maxConnections: 2,
status: 'authenticated',
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: 'app-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({
result: 'authenticated',
request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }),
}),
}),
});
});
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');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }),
}),
});
});
it('audits an unknown Gateway authentication account with its source IP', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue(null);
await expect(service.authenticateInboundApplication({
account: 'ATTACKER',
authSource: 'invalid-auth-source',
timestamp: 120000000,
remoteIp: '203.0.113.9',
version: 'cmpp30',
requestedVersion: 48,
})).rejects.toThrow('CMPP account is invalid or disabled');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: undefined,
resourceId: 'ATTACKER',
ipAddress: '203.0.113.9',
detail: expect.objectContaining({
result: 'failed',
request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }),
}),
}),
});
});
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<Record<string, any>> = [];
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<Record<string, any>> = [];
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<string, unknown> | 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,
cmppSubmitSequenceId: '501',
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-1',
cmppRegisteredDelivery: true,
});
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
id: 'long-group-receipt-1',
messageId: 'MSG-LONG-GROUP-1',
segmentTotal: 2,
segments: [
{ segmentIndex: 1, sequenceId: '501', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '502', registeredDelivery: true },
],
});
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(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, {
data: expect.objectContaining({
dedupeKey: 'receipt:record-long:segment:1',
payload: expect.objectContaining({ submitSequenceId: 501, clientSegmentIndex: 1, clientSegmentTotal: 2 }),
}),
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(2, {
data: expect.objectContaining({
dedupeKey: 'receipt:record-long:segment:2',
payload: expect.objectContaining({ submitSequenceId: 502, clientSegmentIndex: 2, clientSegmentTotal: 2 }),
}),
});
});
it('treats one delivered receipt as the whole long-message success only for a message-level receipt channel', async () => {
const { service, prisma } = createService();
prisma.smsChannel.findUnique.mockResolvedValue({
id: 'channel-1',
config: { longMessageReceiptMode: 'message_level' },
});
prisma.smsMessageRecord.findUnique.mockResolvedValue({
id: 'record-message-level',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-MESSAGE-LEVEL',
submitId: 'SUB-MESSAGE-LEVEL',
phoneNumber: '13127620092',
channelId: 'channel-1',
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
status: 'submitted',
billingUnits: 2,
});
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
id: 'submit-message-level',
submitId: 'SUB-MESSAGE-LEVEL',
channelId: 'channel-1',
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
});
prisma.smsMessageSegmentAudit.findMany
.mockResolvedValueOnce([
{ id: 'segment-1', receiptStatus: 'delivered' },
{ id: 'segment-2', receiptStatus: null },
])
.mockResolvedValueOnce([
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', compensationType: 'supplier_message_level_receipt', deliveredAt: new Date() },
]);
await service.handleReceipt({
messageId: 'MSG-MESSAGE-LEVEL',
channelId: 'channel-1',
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
phoneNumber: '13127620092',
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
});
expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
messageRecordId: 'record-message-level',
submitRecordId: 'submit-message-level',
receiptStatus: null,
}),
data: expect.objectContaining({
receiptStatus: 'delivered',
compensationType: 'supplier_message_level_receipt',
}),
}));
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'record-message-level' },
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
}));
});
it('records a receipt anomaly when a message-level success is followed by a failure for the same attempt', async () => {
const { service, prisma, billing } = createService();
prisma.smsChannel.findUnique.mockResolvedValue({
id: 'channel-1',
config: { longMessageReceiptMode: 'message_level' },
});
prisma.smsMessageRecord.findUnique.mockResolvedValue({
id: 'record-conflict',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-CONFLICT',
submitId: 'SUB-CONFLICT',
phoneNumber: '13127620092',
channelId: 'channel-1',
gatewayMessageId: 'GW-CONFLICT-1',
status: 'delivered',
billingUnits: 2,
});
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
id: 'segment-conflict-2',
messageRecordId: 'record-conflict',
submitRecordId: 'submit-conflict',
submitId: 'SUB-CONFLICT',
channelId: 'channel-1',
gatewayMessageId: 'GW-CONFLICT-2',
segmentIndex: 2,
segmentTotal: 2,
});
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
]);
await service.handleReceipt({
messageId: 'MSG-CONFLICT',
channelId: 'channel-1',
gatewayMessageId: 'GW-CONFLICT-2',
phoneNumber: '13127620092',
receiptStatus: 'undelivered',
rawStatus: 'UNDELIV',
errorCode: 'SP_CONFLICT',
});
expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' },
create: expect.objectContaining({
anomalyType: 'aggregate_success_then_failure',
previousStatus: 'delivered',
incomingStatus: 'undelivered',
}),
}));
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'failed' }),
}));
expect(billing.refund).not.toHaveBeenCalled();
});
it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => {
const { service, prisma } = createService();
let claimedDelivery: Record<string, unknown> | 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,
cmppSubmitSequenceId: '601',
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-FAIL',
cmppRegisteredDelivery: true,
});
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
id: 'long-group-receipt-fail',
messageId: 'MSG-LONG-GROUP-FAIL',
segmentTotal: 2,
segments: [
{ segmentIndex: 1, sequenceId: '601', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '602', registeredDelivery: true },
],
});
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).toHaveBeenCalledTimes(2);
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('marks a pending gateway submit exception as resolved without requeueing it', async () => {
const { service, prisma } = createService();
prisma.gatewaySubmitDeadLetter.findUnique
.mockResolvedValueOnce({
id: 'dead-1',
tenantId: 'tenant-1',
status: 'pending',
messageId: 'MSG-1',
submitId: 'SUB-1',
})
.mockResolvedValueOnce({ id: 'dead-1', status: 'resolved', resolvedStatus: 'manually_resolved' });
prisma.gatewaySubmitDeadLetter.updateMany.mockResolvedValueOnce({ count: 1 });
await expect(service.resolveGatewaySubmitDeadLetter('dead-1', 'user-1')).resolves.toEqual(
expect.objectContaining({ status: 'resolved', resolvedStatus: 'manually_resolved' }),
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: expect.objectContaining({
status: 'resolved',
resolvedAt: expect.any(Date),
resolvedStatus: 'manually_resolved',
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 'user-1',
action: 'gateway.submit_dead_letter_resolved',
resourceId: 'dead-1',
}),
});
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',
version: 'cmpp30',
requestedVersion: 48,
})).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', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, status: 'submitted', cmppSubmitSequenceId: '701', cmppRegisteredDelivery: true, timeoutAt: null },
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-2', phoneNumber: '13900000002', amountCents: 3, billingUnits: 1, status: 'unknown', cmppSubmitSequenceId: '702', cmppRegisteredDelivery: true, timeoutAt: null },
]);
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 },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } },
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: 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.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
dedupeKey: 'receipt:record-1',
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 701 }),
}),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
});
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
const prisma = createPrismaMock();
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) };
const { service } = createService(prisma, openApi);
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-http-timeout',
tenantId: 'tenant-1',
batchTaskId: null,
applicationId: 'app-1',
messageId: 'MSG-HTTP-TIMEOUT',
phoneNumber: '13800000001',
amountCents: 0,
billingUnits: 1,
status: 'submitted',
cmppSubmitSequenceId: null,
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: null,
timeoutAt: null,
}]);
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 });
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-1',
messageRecordId: 'record-http-timeout',
eventType: 'receipt',
payload: expect.objectContaining({
receiptStatus: 'undelivered',
rawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
}),
}));
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
});
it('recovers timeout refund and downstream queueing when the prior scan stopped before setting the outbox marker', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-timeout-recovery',
tenantId: 'tenant-1',
batchTaskId: null,
applicationId: 'app-1',
messageId: 'MSG-TIMEOUT-RECOVERY',
phoneNumber: '13800000001',
amountCents: 3,
billingUnits: 1,
status: 'timeout',
cmppSubmitSequenceId: '703',
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: true,
timeoutAt: new Date('2026-08-01T00:00:00.000Z'),
}]);
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 });
expect(billing.refund).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
dedupeKey: 'receipt:record-timeout-recovery',
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 703 }),
}),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-timeout-recovery', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
});
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 previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_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';
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_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;
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
jest.useRealTimers();
}
});
});