3778 lines
148 KiB
TypeScript
3778 lines
148 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',
|
|
template: { signature: { id: 'sig-1', name: '签名' } },
|
|
};
|
|
const channel = {
|
|
id: 'channel-1',
|
|
code: 'CMPP-A',
|
|
account: 'cmpp-account',
|
|
srcId: '10690000',
|
|
rateLimitPerSecond: 100,
|
|
unitPrice: 3,
|
|
status: 'active',
|
|
carrier: 'mobile',
|
|
sendRegion: '全国',
|
|
gatewayHost: '127.0.0.1',
|
|
gatewayPort: 17890,
|
|
protocol: 'CMPP',
|
|
passwordCipher: 'secret',
|
|
cmppVersion: '3.0',
|
|
config: { serviceId: 'SMS' },
|
|
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
|
};
|
|
const route = {
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
priority: 100,
|
|
status: 'active',
|
|
group: {
|
|
id: 'group-1',
|
|
name: '默认通道组',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: true,
|
|
retryTimeLimitHours: 72,
|
|
retryTimeLimitMinutes: 4320,
|
|
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
|
|
},
|
|
};
|
|
const prisma = {
|
|
tenant: {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
|
},
|
|
smsApplication: {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal' }),
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }]),
|
|
findFirst: jest.fn().mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
cmppEnterpriseCode: 'SP0001',
|
|
secretHash: 'secret-hash',
|
|
status: 'active',
|
|
interfaceEnabled: true,
|
|
cmppMaxConnections: 2,
|
|
queuePriority: 'normal',
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
}),
|
|
},
|
|
smsTemplate: {
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
content: 'hello',
|
|
auditStatus: 'approved',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
|
|
}),
|
|
findFirst: jest.fn().mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
auditStatus: 'approved',
|
|
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
|
}),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
smsSignature: {
|
|
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
|
|
},
|
|
smsDrainageInfo: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
smsSendTask: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
},
|
|
smsBatchTask: {
|
|
create: jest.fn().mockResolvedValue(task),
|
|
findUnique: jest.fn().mockResolvedValue(task),
|
|
findFirst: jest.fn().mockResolvedValue(task),
|
|
findMany: jest.fn(),
|
|
update: jest.fn().mockResolvedValue(task),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
smsApiRequest: {
|
|
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
|
|
findMany: jest.fn(),
|
|
},
|
|
smsMessageRecord: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
findUnique: jest.fn().mockResolvedValue(message),
|
|
findFirst: jest.fn().mockResolvedValue(message),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]),
|
|
},
|
|
channelRouteRule: {
|
|
findFirst: jest.fn().mockResolvedValue(route),
|
|
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', applicationId: 'app-1' }]),
|
|
},
|
|
phoneCarrierRule: {
|
|
findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]),
|
|
},
|
|
phoneSegment: {
|
|
findMany: jest.fn().mockResolvedValue([{ prefix: '1380000', province: '山东', city: '济南' }]),
|
|
},
|
|
smsChannel: {
|
|
findFirst: jest.fn().mockResolvedValue(channel),
|
|
findUnique: jest.fn().mockResolvedValue(channel),
|
|
},
|
|
cmppSubmitSession: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'session-1' }),
|
|
},
|
|
smsSubmitRecord: {
|
|
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }),
|
|
findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve(
|
|
where.retryOfSubmitRecordId
|
|
? null
|
|
: { id: 'submit-1', messageRecordId: 'record-1', channelId: 'channel-1', submitId: 'SUB-1', submitStatus: 'accepted' },
|
|
)),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
smsMessageSegmentAudit: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'segment-1' }),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
cmppInboundLongMessage: {
|
|
create: jest.fn(),
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
update: jest.fn(),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
cmppInboundLongMessageSegment: {
|
|
create: jest.fn(),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
channelSignatureReportTask: {
|
|
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
|
|
findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))),
|
|
},
|
|
smsReceiptRecord: {
|
|
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
|
|
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
findMany: jest.fn(),
|
|
},
|
|
smsUplinkMessage: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
|
findMany: jest.fn(),
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'uplink-1', matchStatus: 'matched', matchCandidates: [] }),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
|
},
|
|
smsUplinkMatchCandidate: {
|
|
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findFirst: jest.fn().mockResolvedValue({
|
|
id: 'candidate-1',
|
|
uplinkMessageId: 'uplink-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
matchSource: 'phone_window',
|
|
confidence: 55,
|
|
reason: '手机号 72 小时窗口候选下发 MSG-1',
|
|
status: 'pending',
|
|
application: { id: 'app-1', name: '应用A', cmppAccount: '100001' },
|
|
messageRecord: { id: 'record-1', messageId: 'MSG-1', content: 'hello' },
|
|
uplinkMessage: {
|
|
id: 'uplink-1',
|
|
tenantId: null,
|
|
applicationId: null,
|
|
messageRecordId: null,
|
|
messageId: null,
|
|
channelId: 'channel-1',
|
|
phoneNumber: '13800000001',
|
|
destId: '10690000',
|
|
content: '回复TD',
|
|
matchStatus: 'ambiguous',
|
|
receivedAt: new Date('2026-07-08T12:00:00.000Z'),
|
|
},
|
|
}),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
|
|
},
|
|
cmppDownstreamDelivery: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', ...data, createdAt: new Date(), updatedAt: new Date() })),
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 0,
|
|
manualRetryCount: 0,
|
|
status: 'failed',
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
lastError: null,
|
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
|
application: { cmppAccount: '100001' },
|
|
}),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', ...data })),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
cmppDownstreamDeliveryAttempt: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'delivery-attempt-1' }),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
upstreamReceiptInbox: {
|
|
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({
|
|
id: 'receipt-inbox-1',
|
|
attemptCount: 0,
|
|
receivedAt: new Date(),
|
|
...create,
|
|
})),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'receipt-inbox-1', ...data })),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
gatewaySubmitDeadLetter: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'dead-1' }),
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'dead-1',
|
|
tenantId: 'tenant-1',
|
|
channelId: 'channel-1',
|
|
streamMessageId: '1710000000000-0',
|
|
submitId: 'SUB-1',
|
|
messageId: 'MSG-1',
|
|
status: 'pending',
|
|
manualRetryCount: 0,
|
|
commandPayload: {
|
|
schemaVersion: 'v1',
|
|
messageType: 'SubmitCommand',
|
|
traceId: 'trace-1',
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
createdAt: '2026-07-08T12:00:00.000Z',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
submitId: 'SUB-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
signature: '签名',
|
|
templateId: 'tpl-1',
|
|
billingUnits: 1,
|
|
queuePriority: 'normal',
|
|
route: { channelCode: 'CMPP-A', cmppAccountCode: 'account-a', priority: 0 },
|
|
cmpp: { serviceId: 'SMS', srcId: '10690000', registeredDelivery: 1, msgFmt: 8 },
|
|
upstream: { gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'account-a', passwordCipher: 'secret', cmppVersion: '3.0' },
|
|
retry: { attempt: 0, maxAttempts: 1 },
|
|
},
|
|
}),
|
|
update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
gatewayDownstreamRecoveryStatus: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
upsert: jest.fn().mockResolvedValue({
|
|
id: 'recover-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
account: '100001',
|
|
gatewayInstanceId: 'gateway-a',
|
|
state: 'waiting_connection',
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
|
attemptCount: 2,
|
|
failureCategory: 'client_disconnected',
|
|
nextRetryAt: new Date('2026-07-08T12:10:00.000Z'),
|
|
lastError: 'downstream client is not connected',
|
|
}),
|
|
},
|
|
smsBillingRecord: {
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
|
update: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
accountTransaction: {
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
},
|
|
operationLog: {
|
|
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
|
|
},
|
|
enterpriseBlacklist: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
globalBlacklist: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
$queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]),
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
$transaction: jest.fn(),
|
|
};
|
|
prisma.$transaction.mockImplementation((operations: any) => typeof operations === 'function'
|
|
? operations(prisma)
|
|
: Promise.all(operations));
|
|
return prisma;
|
|
}
|
|
|
|
function createService(
|
|
prisma = createPrismaMock(),
|
|
openApi?: { queueWebhookEvent: jest.Mock },
|
|
) {
|
|
const billing = {
|
|
estimateSmsCost: jest.fn().mockReturnValue({
|
|
billingUnitsPerMessage: 1,
|
|
totalBillingUnits: 2,
|
|
unitPrice: 3,
|
|
amountCents: 6,
|
|
}),
|
|
checkAccount: jest.fn().mockResolvedValue({ canSend: true }),
|
|
freeze: jest.fn().mockResolvedValue({ id: 'tx-freeze' }),
|
|
release: jest.fn().mockResolvedValue({ id: 'tx-release' }),
|
|
charge: jest.fn().mockResolvedValue({ id: 'tx-charge' }),
|
|
refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }),
|
|
} as unknown as BillingService;
|
|
const riskReview = {
|
|
evaluateTask: jest.fn().mockResolvedValue({
|
|
status: 'approved',
|
|
reason: null,
|
|
task: { id: 'risk-task-1' },
|
|
}),
|
|
aggregateTemplateMismatch: jest.fn().mockResolvedValue({
|
|
id: 'review-task-1',
|
|
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
|
}),
|
|
} as unknown as RiskReviewService;
|
|
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
|
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
|
return { service, prisma, billing, riskReview };
|
|
}
|
|
|
|
describe('SendChainService', () => {
|
|
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
|
|
const { service, prisma, riskReview, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000001', '13800000002'],
|
|
sourceIp: '127.0.0.1',
|
|
userAgent: 'jest',
|
|
});
|
|
|
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['13800000001', '13800000002'] }));
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ phoneTotal: 2, status: 'ready', progressTotal: 2, auditStatus: 'approved' }),
|
|
});
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
|
|
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
|
|
]),
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' }));
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
|
});
|
|
|
|
it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]);
|
|
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
|
billingUnitsPerMessage: 1,
|
|
totalBillingUnits: 1,
|
|
unitPrice: 3,
|
|
amountCents: 3,
|
|
});
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '23800000002', '13800000002'],
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
|
expect.objectContaining({
|
|
phoneNumber: '23800000002',
|
|
status: 'submit_failed',
|
|
submitStatus: 'rejected',
|
|
errorCode: 'INVALID_PHONE',
|
|
amountCents: 0,
|
|
}),
|
|
expect.objectContaining({
|
|
phoneNumber: '13800000002',
|
|
status: 'submit_failed',
|
|
submitStatus: 'rejected',
|
|
errorCode: 'GLOBAL_BLACKLIST',
|
|
amountCents: 0,
|
|
}),
|
|
]),
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
|
});
|
|
|
|
it('persists the review task id on every message waiting for manual review', async () => {
|
|
const { service, prisma, riskReview } = createService();
|
|
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
|
status: 'pending_review',
|
|
reason: '命中人工审核规则',
|
|
task: { id: 'review-task-1' },
|
|
});
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({
|
|
phoneNumber: '13800000001',
|
|
status: 'pending_review',
|
|
reviewTaskId: 'review-task-1',
|
|
})],
|
|
});
|
|
});
|
|
|
|
it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
|
|
|
await expect(service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000002'],
|
|
})).rejects.toThrow('应用当日发送上限1条');
|
|
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.createMany).not.toHaveBeenCalled();
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('recognizes an approved template for public HTTP content and reads back the api task', async () => {
|
|
const { service, prisma, riskReview } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
|
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
|
|
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' },
|
|
});
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
|
|
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
|
|
await service.createHttpBatchTask({
|
|
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'],
|
|
sourceIp: '127.0.0.1', clientMessageId: 'client-http-1',
|
|
});
|
|
|
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
templateId: 'tpl-http',
|
|
variables: { code: '123456' },
|
|
}));
|
|
expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ id: 'task-1', sourceType: 'api' }),
|
|
}));
|
|
});
|
|
|
|
it('persists the unique longest approved drainage URL match on new message records', async () => {
|
|
const { service, prisma } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved',
|
|
content: '【签名】详情请访问 https://a.example/landing',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
|
|
});
|
|
prisma.smsDrainageInfo.findMany.mockResolvedValue([
|
|
{ id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') },
|
|
{ id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') },
|
|
]);
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
|
content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'],
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ signatureId: 'sig-1', drainageInfoId: 'drain-long' })],
|
|
});
|
|
});
|
|
|
|
it('rejects a task when the submitted content no longer matches the selected approved template', async () => {
|
|
const { service, prisma, riskReview } = createService();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
|
|
content: '【签名】验证码${code}', auditStatus: 'approved',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
|
|
await expect(service.createBatchTask({
|
|
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
|
content: '【签名】被篡改的正文', phones: ['13800000001'],
|
|
})).rejects.toThrow('短信内容与选定的审核模板不匹配');
|
|
|
|
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects free content without an approved leading signature', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true,
|
|
customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send',
|
|
});
|
|
prisma.smsSignature.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(service.createBatchTask({
|
|
tenantId: 'tenant-1', applicationId: 'app-1', content: '没有签名的自由内容', phones: ['13800000001'],
|
|
})).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头');
|
|
});
|
|
|
|
it('allows signed free content only when the application explicitly uses direct send', async () => {
|
|
const { service, prisma } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true,
|
|
customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send',
|
|
});
|
|
prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' });
|
|
|
|
await expect(service.createBatchTask({
|
|
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】允许直接发送的自由内容', phones: ['13800000001'],
|
|
})).resolves.toBeDefined();
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ signatureId: 'sig-1' })],
|
|
});
|
|
});
|
|
|
|
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
|
|
const { service, prisma, riskReview } = createService();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
|
|
content: '【签名】详情 https://blocked.example', auditStatus: 'approved',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
prisma.smsDrainageInfo.findMany.mockResolvedValue([
|
|
{ id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') },
|
|
]);
|
|
|
|
await expect(service.createBatchTask({
|
|
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
|
content: '【签名】详情 https://blocked.example', phones: ['13800000001'],
|
|
})).resolves.toBeDefined();
|
|
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
|
|
});
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({
|
|
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
|
|
})],
|
|
});
|
|
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
const scheduledAt = new Date(Date.now() + 60_000).toISOString();
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
sendMode: 'scheduled',
|
|
scheduledAt,
|
|
});
|
|
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ status: 'scheduled', scheduledAt: expect.any(Date) }),
|
|
});
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ status: 'scheduled' })],
|
|
});
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
|
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' }]);
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
|
|
await expect(service.dispatchDueScheduledTasks(new Date(Date.now() + 120_000))).resolves.toEqual({
|
|
dispatched: 1,
|
|
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
|
data: { status: 'queued' },
|
|
});
|
|
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({ id: 'task-1', status: 'scheduled' }),
|
|
data: { status: 'scheduled_dispatching' },
|
|
});
|
|
});
|
|
|
|
it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
const dueTask = {
|
|
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
|
status: 'scheduled', scheduledAt: new Date(Date.now() - 1_000), updatedAt: new Date(Date.now() - 1_000),
|
|
};
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]);
|
|
prisma.smsBatchTask.updateMany
|
|
.mockResolvedValueOnce({ count: 1 })
|
|
.mockResolvedValueOnce({ count: 0 });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
|
|
const results = await Promise.all([
|
|
service.dispatchDueScheduledTasks(new Date()),
|
|
service.dispatchDueScheduledTasks(new Date()),
|
|
]);
|
|
|
|
expect(results.map((item) => item.dispatched).sort()).toEqual([0, 1]);
|
|
expect(billing.freeze).toHaveBeenCalledTimes(1);
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('recovers a stale claimed task without freezing its balance twice', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
const now = new Date();
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
|
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
|
status: 'scheduled_dispatching', scheduledAt: new Date(now.getTime() - 300_000),
|
|
updatedAt: new Date(now.getTime() - 300_000),
|
|
}]);
|
|
prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
|
|
await expect(service.dispatchDueScheduledTasks(now)).resolves.toEqual({
|
|
dispatched: 1,
|
|
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
|
});
|
|
|
|
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({ id: 'task-1', status: 'scheduled_dispatching', updatedAt: { lt: expect.any(Date) } }),
|
|
data: { status: 'scheduled_recovering' },
|
|
});
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
|
id: 'task-free', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
|
|
}]);
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
|
|
|
|
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
|
dispatched: 0,
|
|
results: [{ taskId: 'task-free', status: 'retrying', reason: 'Redis unavailable' }],
|
|
});
|
|
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'task-free' },
|
|
data: { status: 'scheduled_dispatching', rejectReason: '调度将在超时后恢复:Redis unavailable' },
|
|
});
|
|
});
|
|
|
|
it('automatically scans and dispatches due scheduled tasks after application startup', async () => {
|
|
jest.useFakeTimers();
|
|
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
|
const { service } = createService();
|
|
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
|
|
try {
|
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
|
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
|
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
|
|
service.onModuleInit();
|
|
await jest.advanceTimersByTimeAsync(1_000);
|
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
await service.onModuleDestroy();
|
|
} finally {
|
|
if (previousReceiptEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousReceiptEnabled;
|
|
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
|
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('cancels scheduled tasks before dispatch', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', status: 'scheduled' });
|
|
|
|
await service.cancelBatchTask('task-1');
|
|
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
|
data: { status: 'canceled', errorMessage: '定时任务已取消' },
|
|
});
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'task-1' },
|
|
data: { status: 'canceled', canceledAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it('lists only client-created batch tasks for task progress', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-client', tenantId: 'tenant-1', sourceType: 'client' }]);
|
|
prisma.smsMessageRecord.groupBy.mockResolvedValue([]);
|
|
|
|
await service.listBatchTasks('tenant-1', 'queued');
|
|
|
|
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' },
|
|
}));
|
|
});
|
|
|
|
it('does not expose CMPP internal tasks through client task detail or messages', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(service.getBatchTask('task-cmpp', 'tenant-1', 'client')).rejects.toThrow('SMS batch task not found');
|
|
await expect(service.listClientTaskMessages('task-cmpp', 'tenant-1')).rejects.toThrow('SMS batch task not found');
|
|
expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { id: 'task-cmpp', tenantId: 'tenant-1', sourceType: 'client' },
|
|
}));
|
|
expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('paginates the real phone list for an admin batch task', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', sourceType: 'client' });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{ id: 'record-1', phoneNumber: '13800000001', province: '上海', carrier: 'mobile', status: 'delivered' },
|
|
]);
|
|
prisma.smsMessageRecord.count.mockResolvedValue(21);
|
|
|
|
await expect(service.listAdminBatchTaskMessages('task-1', '138', 2, 20)).resolves.toEqual({
|
|
items: [expect.objectContaining({ phoneNumber: '13800000001' })],
|
|
total: 21,
|
|
page: 2,
|
|
pageSize: 20,
|
|
});
|
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', phoneNumber: { contains: '138' } },
|
|
select: {
|
|
id: true,
|
|
phoneNumber: true,
|
|
province: true,
|
|
carrier: true,
|
|
status: true,
|
|
},
|
|
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
|
|
skip: 20,
|
|
take: 20,
|
|
});
|
|
});
|
|
|
|
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
|
service['refreshTaskProgress'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await service.terminateBatchTask('task-1');
|
|
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } },
|
|
data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' },
|
|
});
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'task-1' },
|
|
data: { status: 'canceled', canceledAt: expect.any(Date), rejectReason: '运营终止任务' },
|
|
});
|
|
});
|
|
|
|
it('blocks sending when enterprise certification is not approved', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' });
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('企业认证未通过,不能发送短信');
|
|
});
|
|
|
|
it('blocks sending when application interface is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
customerUnitPrice: 3,
|
|
queuePriority: 'normal',
|
|
});
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('短信应用接口未开通,不能发送短信');
|
|
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns the application enterprise code after Gateway authentication', async () => {
|
|
const { service } = createService();
|
|
|
|
await expect(service.authenticateInboundApplication({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
remoteIp: '127.0.0.1',
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
account: '100001',
|
|
enterpriseCode: 'SP0001',
|
|
maxConnections: 2,
|
|
status: 'authenticated',
|
|
}));
|
|
});
|
|
|
|
it('rejects Gateway authentication when application interface is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
secretHash: 'secret-hash',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
});
|
|
await expect(service.authenticateInboundApplication({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
remoteIp: '127.0.0.1',
|
|
})).rejects.toThrow('CMPP interface is disabled for this application');
|
|
});
|
|
|
|
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
secretHash: 'secret-hash',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
queuePriority: 'normal',
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
});
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
interfaceEnabled: false,
|
|
downstreamReceiptRetryEnabled: true,
|
|
downstreamUplinkRetryEnabled: true,
|
|
httpConfig: { enabled: false },
|
|
});
|
|
|
|
await expect(service.submitInboundMessage({
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
remoteIp: '127.0.0.1',
|
|
})).rejects.toThrow('CMPP account is disabled for new submissions');
|
|
|
|
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => {
|
|
const prisma = createPrismaMock();
|
|
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ queued: true }) };
|
|
const { service } = createService(prisma, openApi);
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
downstreamReceiptRetryEnabled: true,
|
|
downstreamUplinkRetryEnabled: true,
|
|
httpConfig: { enabled: true },
|
|
});
|
|
|
|
await service['queueAndTryDownstreamDelivery']({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
payload: { receiptStatus: 'delivered' },
|
|
});
|
|
|
|
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
applicationId: 'app-1',
|
|
eventType: 'receipt',
|
|
}));
|
|
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('splits every destination in one inbound CMPP Submit into an independent real message record', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
secretHash: 'secret-hash',
|
|
status: 'active',
|
|
interfaceEnabled: true,
|
|
queuePriority: 'normal',
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
});
|
|
let taskIndex = 0;
|
|
prisma.smsBatchTask.create.mockImplementation(({ data }) => {
|
|
taskIndex += 1;
|
|
return Promise.resolve({ id: `task-${taskIndex}`, ...data });
|
|
});
|
|
let messageIndex = 0;
|
|
prisma.smsMessageRecord.create.mockImplementation(({ data }) => {
|
|
messageIndex += 1;
|
|
return Promise.resolve({ id: `record-${messageIndex}`, messageId: data.messageId, ...data });
|
|
});
|
|
|
|
const result = await service.submitInboundMessage({
|
|
account: '100001',
|
|
phoneNumbers: ['13800000001', '13900000002'],
|
|
content: 'hello',
|
|
sequenceId: 777823876,
|
|
remoteIp: '127.0.0.1',
|
|
});
|
|
|
|
expect(result).toEqual(expect.objectContaining({
|
|
accepted: true,
|
|
phoneCount: 2,
|
|
messages: [
|
|
expect.objectContaining({ phoneNumber: '13800000001', messageRecordId: 'record-1' }),
|
|
expect.objectContaining({ phoneNumber: '13900000002', messageRecordId: 'record-2' }),
|
|
],
|
|
}));
|
|
expect(result.messageId).toBe(result.messages[0].messageId);
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
|
|
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
const segments: Array<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,
|
|
});
|
|
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
|
id: 'submit-long',
|
|
submitId: 'SUB-LONG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
});
|
|
prisma.smsMessageSegmentAudit.findMany
|
|
.mockResolvedValueOnce([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-LONG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'delivered' }),
|
|
}));
|
|
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
|
|
|
prisma.smsReceiptRecord.findUnique.mockResolvedValue(null);
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-LONG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { id: 'record-long' },
|
|
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
|
}));
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('creates and sends only one downstream final receipt under concurrent completion', async () => {
|
|
const { service, prisma } = createService();
|
|
let claimedDelivery: Record<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,
|
|
});
|
|
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
|
id: 'segment-2',
|
|
messageRecordId: 'record-long',
|
|
submitRecordId: 'submit-long',
|
|
submitId: 'SUB-LONG-FAIL',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
segmentIndex: 2,
|
|
segmentTotal: 2,
|
|
});
|
|
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() },
|
|
]);
|
|
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve(
|
|
where.id === 'submit-long'
|
|
? {
|
|
id: 'submit-long',
|
|
messageRecordId: 'record-long',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-LONG-FAIL',
|
|
submitStatus: 'accepted',
|
|
}
|
|
: null,
|
|
));
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' });
|
|
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
group: {
|
|
id: 'group-1',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: false,
|
|
retryTimeLimitHours: 72,
|
|
retryTimeLimitMinutes: 4320,
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-LONG-FAIL',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
phoneNumber: '18821203795',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'YL:1014',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
|
where: { id: 'record-long' },
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
receiptStatus: 'undelivered',
|
|
receiptRawStatus: 'YL:1014',
|
|
}),
|
|
});
|
|
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
messageRecordId: 'record-long',
|
|
deliveryType: 'receipt',
|
|
status: 'pending',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsReceiptRecord.findUnique
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({
|
|
id: 'receipt-existing',
|
|
messageRecordId: 'record-1',
|
|
messageRecord: { id: 'record-1', messageId: 'MSG-1', status: 'delivered' },
|
|
});
|
|
|
|
const receipt = {
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-1',
|
|
phoneNumber: '13800000001',
|
|
receiptStatus: 'delivered' as const,
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: '2026-07-01T10:01:00.000Z',
|
|
};
|
|
await service.handleReceipt(receipt);
|
|
await service.handleReceipt(receipt);
|
|
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
|
prisma.smsSubmitRecord.findMany
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'submit-timeout-1',
|
|
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
|
|
},
|
|
{
|
|
id: 'submit-timeout-2',
|
|
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
service.handleReceipt({
|
|
messageId: 'receipt-ambiguous',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-AMBIGUOUS',
|
|
phoneNumber: '13800000001',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
}),
|
|
).rejects.toThrow('SMS message record not found');
|
|
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks submit when signature is not approved on the selected channel', async () => {
|
|
const { service, prisma } = createService();
|
|
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
|
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
billingUnits: 1,
|
|
unitPrice: 3,
|
|
amountCents: 3,
|
|
status: 'queued',
|
|
queuePriority: 'normal',
|
|
batchTask: { sourceType: 'cmpp' },
|
|
template: { signature: { id: 'sig-1', name: '签名' } },
|
|
});
|
|
|
|
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
|
expect.objectContaining({ submitted: false, status: 'failed', reason: '无已报备通过且在线的可用通道' }),
|
|
);
|
|
expect(gatewayAdd).not.toHaveBeenCalled();
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ receiptStatus: 'undelivered', errorCode: 'ROUTE' }),
|
|
});
|
|
});
|
|
|
|
it('only selects channel group items allocated to the matched carrier', async () => {
|
|
const { service, prisma } = createService();
|
|
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
|
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
group: {
|
|
id: 'group-1',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: true,
|
|
retryTimeLimitHours: 72,
|
|
retryTimeLimitMinutes: 4320,
|
|
items: [
|
|
{
|
|
id: 'wrong-item',
|
|
groupId: 'group-1',
|
|
channelId: 'channel-unicom',
|
|
carrier: 'unicom',
|
|
priority: 1,
|
|
province: null,
|
|
channel: {
|
|
id: 'channel-unicom',
|
|
code: 'CMPP-U',
|
|
account: 'u',
|
|
srcId: '1061',
|
|
rateLimitPerSecond: 100,
|
|
unitPrice: 3,
|
|
status: 'active',
|
|
carrier: 'all',
|
|
sendRegion: '全国',
|
|
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
|
},
|
|
},
|
|
{
|
|
id: 'mobile-item',
|
|
groupId: 'group-1',
|
|
channelId: 'channel-all',
|
|
carrier: 'mobile',
|
|
priority: 2,
|
|
province: null,
|
|
channel: {
|
|
id: 'channel-all',
|
|
code: 'CMPP-ALL',
|
|
account: 'all',
|
|
srcId: '1062',
|
|
rateLimitPerSecond: 100,
|
|
unitPrice: 3,
|
|
status: 'active',
|
|
carrier: 'all',
|
|
sendRegion: '全国',
|
|
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
|
expect.objectContaining({ channelId: 'channel-all' }),
|
|
);
|
|
expect(gatewayAdd).toHaveBeenCalledWith(
|
|
'submit-command',
|
|
expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }),
|
|
);
|
|
});
|
|
|
|
it('records receipts and uplink messages from gateway events', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
sequenceId: 7,
|
|
gatewayMessageId: 'GW-1',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: '2026-07-01T10:01:00.000Z',
|
|
});
|
|
await service.handleUplink({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
sequenceId: 8,
|
|
phoneNumber: '13800000001',
|
|
destId: '10690000',
|
|
content: 'TD',
|
|
receivedAt: '2026-07-01T10:02:00.000Z',
|
|
});
|
|
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }),
|
|
});
|
|
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', content: 'TD' }),
|
|
});
|
|
});
|
|
|
|
it('records ambiguous uplink match candidates for shared access numbers', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.channelRouteRule.findMany.mockResolvedValue([
|
|
{ applicationId: 'app-1' },
|
|
{ applicationId: 'app-2' },
|
|
]);
|
|
prisma.smsApplication.findMany.mockResolvedValue([
|
|
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
|
|
{ id: 'app-2', tenantId: 'tenant-2', name: '应用B' },
|
|
]);
|
|
|
|
await service.handleUplink({
|
|
channelId: 'channel-1',
|
|
sequenceId: 8,
|
|
phoneNumber: '13800000001',
|
|
destId: '10690000',
|
|
content: '回复TD',
|
|
receivedAt: '2026-07-01T10:02:00.000Z',
|
|
});
|
|
|
|
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: undefined,
|
|
applicationId: undefined,
|
|
matchStatus: 'ambiguous',
|
|
matchReason: '接入号匹配多个应用',
|
|
}),
|
|
});
|
|
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
|
|
data: [
|
|
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', matchSource: 'access_number', confidence: 70 }),
|
|
expect.objectContaining({ tenantId: 'tenant-2', applicationId: 'app-2', matchSource: 'access_number', confidence: 70 }),
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
});
|
|
|
|
it('claims an ambiguous uplink candidate and queues downstream uplink delivery', async () => {
|
|
const { service, prisma } = createService();
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
|
|
|
await service.claimUplinkMatchCandidate('uplink-1', 'candidate-1', 'admin-1');
|
|
|
|
expect(prisma.smsUplinkMessage.update).toHaveBeenCalledWith({
|
|
where: { id: 'uplink-1' },
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
messageId: 'MSG-1',
|
|
matchStatus: 'matched',
|
|
}),
|
|
});
|
|
expect(prisma.smsUplinkMatchCandidate.updateMany).toHaveBeenCalledWith({
|
|
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
|
|
data: { status: 'rejected' },
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'uplink',
|
|
status: 'pending',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('records gateway submit exceptions and safely allows manual requeue', async () => {
|
|
const { service, prisma } = createService();
|
|
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0');
|
|
|
|
await service.recordGatewaySubmitDeadLetter({
|
|
streamMessageId: '1710000000000-0',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
channelId: 'channel-1',
|
|
traceId: 'trace-1',
|
|
messageId: 'MSG-1',
|
|
submitId: 'SUB-1',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
failureMessage: 'network down',
|
|
attempts: 3,
|
|
maxAttempts: 3,
|
|
commandPayload: { messageType: 'SubmitCommand', submitId: 'SUB-1' },
|
|
rawPayload: '{"messageType":"SubmitCommand"}',
|
|
deadLetteredAt: '2026-07-08T12:00:00.000Z',
|
|
});
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith({
|
|
where: { streamMessageId: '1710000000000-0' },
|
|
update: expect.objectContaining({
|
|
submitId: 'SUB-1',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
attempts: 3,
|
|
}),
|
|
create: expect.objectContaining({
|
|
streamMessageId: '1710000000000-0',
|
|
failureMessage: 'network down',
|
|
}),
|
|
});
|
|
|
|
await service.requeueGatewaySubmitDeadLetter('dead-1', {
|
|
confirmedNotSubmitted: true,
|
|
reason: '确认通道连接失败且运营商未收到该短信',
|
|
operatorId: 'user-1',
|
|
});
|
|
|
|
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
|
expect.objectContaining({ submitId: 'SUB-1' }),
|
|
'gateway:submit:requeue:dead-1:1',
|
|
);
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'dead-1', status: 'pending' },
|
|
data: { status: 'requeueing' },
|
|
});
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'dead-1', status: 'requeueing' },
|
|
data: expect.objectContaining({
|
|
status: 'requeued',
|
|
manualRetryCount: { increment: 1 },
|
|
lastRetryStreamId: '1710000001000-0',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.submit_dead_letter_requeue',
|
|
resource: 'gateway_submit_dead_letter',
|
|
resourceId: 'dead-1',
|
|
userId: 'user-1',
|
|
detail: expect.objectContaining({
|
|
reason: '确认通道连接失败且运营商未收到该短信',
|
|
confirmedNotSubmitted: true,
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('blocks submit exception requeue when the upstream result may already be accepted', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValueOnce({
|
|
id: 'record-1',
|
|
messageId: 'MSG-1',
|
|
status: 'submitted',
|
|
submitStatus: 'accepted',
|
|
receiptStatus: null,
|
|
});
|
|
|
|
await expect(service.requeueGatewaySubmitDeadLetter('dead-1', {
|
|
confirmedNotSubmitted: true,
|
|
reason: '尝试重新发送这条短信',
|
|
})).rejects.toThrow('为避免重复发送,禁止重新入队');
|
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.recordGatewaySubmitDeadLetter({
|
|
streamMessageId: '1710000000000-0',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
failureMessage: 'repeated report',
|
|
attempts: 3,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
|
update: expect.not.objectContaining({ status: expect.anything(), resolvedAt: expect.anything(), resolvedStatus: expect.anything() }),
|
|
}));
|
|
});
|
|
|
|
it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
cmppEnterpriseCode: 'SP0001',
|
|
secretHash: 'secret-hash',
|
|
status: 'disabling',
|
|
interfaceEnabled: true,
|
|
cmppMaxConnections: 2,
|
|
queuePriority: 'normal',
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
});
|
|
|
|
await expect(service.authenticateInboundApplication({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
remoteIp: '127.0.0.1',
|
|
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
|
|
await expect(service.submitInboundMessage({
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
remoteIp: '127.0.0.1',
|
|
})).rejects.toThrow('disabled for new submissions');
|
|
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
status: 'deleted',
|
|
interfaceEnabled: true,
|
|
tenant: { id: 'tenant-1', status: 'deleted', certificationStatus: 'approved' },
|
|
});
|
|
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]);
|
|
|
|
await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100 }))
|
|
.resolves.toEqual([]);
|
|
});
|
|
|
|
it('persists each upstream SubmitResp segment before the aggregate result arrives', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.handleSubmitSegmentResult({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-1',
|
|
segmentTotal: 3,
|
|
segmentIndex: 1,
|
|
sequenceId: 71,
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
submitStatus: 'accepted',
|
|
submittedAt: '2026-07-25T15:00:00.000Z',
|
|
});
|
|
|
|
expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: {
|
|
messageRecordId_submitId_segmentIndex: {
|
|
messageRecordId: 'record-1',
|
|
submitId: 'SUB-1',
|
|
segmentIndex: 1,
|
|
},
|
|
},
|
|
create: expect.objectContaining({
|
|
segmentTotal: 3,
|
|
sequenceId: 71,
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
submitStatus: 'accepted',
|
|
}),
|
|
}));
|
|
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { id: 'submit-1', gatewayMessageId: null },
|
|
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }),
|
|
}));
|
|
});
|
|
|
|
it('rejects a legacy segment result when multiple channel attempts could match', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
|
{ id: 'submit-2', submitId: 'SUB-2', messageRecordId: 'record-1', channelId: 'channel-1' },
|
|
{ id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: 'channel-1' },
|
|
]);
|
|
|
|
await expect(service.handleSubmitSegmentResult({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
segmentTotal: 2,
|
|
segmentIndex: 1,
|
|
sequenceId: 72,
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
submitStatus: 'accepted',
|
|
})).rejects.toThrow('cannot be matched uniquely');
|
|
|
|
expect(prisma.smsMessageSegmentAudit.upsert).not.toHaveBeenCalled();
|
|
expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('durably intakes an upstream receipt before asynchronous business matching', async () => {
|
|
const { service, prisma } = createService();
|
|
jest.spyOn(service as any, 'processUpstreamReceiptInboxRecord').mockResolvedValue(false);
|
|
|
|
await expect(service.intakeReceipt({
|
|
messageId: 'receipt-9001',
|
|
channelId: 'channel-1',
|
|
connectionId: 'gateway-connection-2',
|
|
sequenceId: 81,
|
|
gatewayMessageId: '9001',
|
|
phoneNumber: '13800000001',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: '2026-07-25T15:01:00.000Z',
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
accepted: true,
|
|
inboxId: 'receipt-inbox-1',
|
|
}));
|
|
|
|
expect(prisma.upstreamReceiptInbox.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
|
create: expect.objectContaining({
|
|
incomingChannelId: 'channel-1',
|
|
incomingConnectionId: 'gateway-connection-2',
|
|
upstreamAccount: 'cmpp-account',
|
|
upstreamHost: '127.0.0.1',
|
|
upstreamPort: 17890,
|
|
protocol: 'CMPP',
|
|
protocolVersion: '3.0',
|
|
gatewayMessageId: '9001',
|
|
status: 'pending',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('does not downgrade an early terminal receipt when the aggregate submit result arrives later', async () => {
|
|
const { service, prisma } = createService();
|
|
const terminalMessage = {
|
|
id: 'record-1', messageId: 'MSG-1', tenantId: null, batchTaskId: null, applicationId: null,
|
|
channelId: 'channel-1', submitId: 'SUB-1', gatewayMessageId: 'GW-SEG-1',
|
|
phoneNumber: '13800000001', billingUnits: 1, amountCents: 0, status: 'failed',
|
|
};
|
|
prisma.smsMessageRecord.findFirst.mockResolvedValue(terminalMessage);
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(terminalMessage);
|
|
prisma.smsMessageRecord.updateMany
|
|
.mockResolvedValueOnce({ count: 0 })
|
|
.mockResolvedValueOnce({ count: 1 });
|
|
|
|
await service.handleSubmitResult({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-1',
|
|
sequenceId: 7,
|
|
gatewayMessageId: 'GW-1',
|
|
submitStatus: 'accepted',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } },
|
|
data: expect.objectContaining({ status: 'submitted' }),
|
|
}));
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(2, {
|
|
where: { id: 'record-1', gatewayMessageId: null },
|
|
data: expect.objectContaining({ gatewayMessageId: 'GW-1' }),
|
|
});
|
|
});
|
|
|
|
it('recovers a stale submit requeue with the same Redis idempotency key', async () => {
|
|
const { service, prisma } = createService();
|
|
const stale = {
|
|
...await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } }),
|
|
status: 'requeueing',
|
|
updatedAt: new Date('2026-07-21T07:00:00.000Z'),
|
|
};
|
|
prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]);
|
|
const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0');
|
|
|
|
await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1, failed: 0 });
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, {
|
|
where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt },
|
|
data: { status: 'requeue_recovering' },
|
|
});
|
|
expect(publish).toHaveBeenCalledWith(
|
|
stale.commandPayload,
|
|
'gateway:submit:requeue:dead-1:1',
|
|
);
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, {
|
|
where: { id: 'dead-1', status: 'requeue_recovering' },
|
|
data: expect.objectContaining({
|
|
status: 'requeued',
|
|
manualRetryCount: { increment: 1 },
|
|
lastRetryStreamId: '1710000001000-0',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => {
|
|
const { service, prisma } = createService();
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' });
|
|
prisma.cmppDownstreamDelivery.updateMany
|
|
.mockResolvedValueOnce({ count: 1 })
|
|
.mockResolvedValueOnce({ count: 0 });
|
|
|
|
const results = await Promise.allSettled([
|
|
service.requeueDownstreamDelivery('delivery-1'),
|
|
service.requeueDownstreamDelivery('delivery-1'),
|
|
]);
|
|
|
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
|
expect(service['postGatewayControl']).toHaveBeenCalledTimes(1);
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: 'delivery-1',
|
|
status: 'failed',
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
},
|
|
data: expect.objectContaining({
|
|
status: 'manual_requeueing',
|
|
manualRetryCount: { increment: 1 },
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('recovers a stale downstream manual-requeue claim into the Gateway pending path', async () => {
|
|
const { service, prisma } = createService();
|
|
const updatedAt = new Date('2026-07-21T07:00:00.000Z');
|
|
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]);
|
|
|
|
await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1 });
|
|
|
|
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({
|
|
where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } },
|
|
select: { id: true, updatedAt: true },
|
|
orderBy: { updatedAt: 'asc' },
|
|
take: 500,
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'delivery-1', status: 'manual_requeueing', updatedAt },
|
|
data: {
|
|
status: 'pending',
|
|
nextRetryAt: null,
|
|
lastError: '人工重投进程中断,已恢复为待投递',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('records gateway downstream recovery statuses', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await expect(service.recordGatewayDownstreamRecoveryStatus({
|
|
account: '100001',
|
|
gatewayInstanceId: 'gateway-a',
|
|
state: 'waiting_connection',
|
|
lastAttemptAt: '2026-07-08T12:00:00.000Z',
|
|
nextRetryAt: '2026-07-08T12:10:00.000Z',
|
|
attemptCount: 2,
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: '2026-07-08T12:00:30.000Z',
|
|
lastError: 'downstream client is not connected',
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
id: 'recover-1',
|
|
account: '100001',
|
|
state: 'waiting_connection',
|
|
}));
|
|
|
|
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { account: '100001' },
|
|
update: expect.objectContaining({
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
|
failureCategory: 'client_disconnected',
|
|
}),
|
|
create: expect.objectContaining({
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
|
failureCategory: 'client_disconnected',
|
|
}),
|
|
}));
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_recovery_status_changed',
|
|
resource: 'gateway_downstream_recovery_status',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('does not append recovery audit logs when only periodic timestamps change', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({
|
|
state: 'waiting_connection',
|
|
gatewayInstanceId: 'gateway-a',
|
|
lockOwner: 'gateway-a',
|
|
failureCategory: 'client_disconnected',
|
|
lastError: 'downstream client is not connected',
|
|
lastSkipReason: null,
|
|
});
|
|
|
|
await service.recordGatewayDownstreamRecoveryStatus({
|
|
account: '100001',
|
|
gatewayInstanceId: 'gateway-a',
|
|
state: 'waiting_connection',
|
|
lastAttemptAt: '2026-07-08T12:01:00.000Z',
|
|
nextRetryAt: '2026-07-08T12:11:00.000Z',
|
|
attemptCount: 3,
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: '2026-07-08T12:01:30.000Z',
|
|
lastError: 'downstream client is not connected',
|
|
});
|
|
|
|
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled();
|
|
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('marks downstream delivery as failed after reaching retry limit', async () => {
|
|
const { service, prisma } = createService();
|
|
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
|
process.env.CMPP_DOWNSTREAM_MAX_RETRIES = '2';
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 1,
|
|
lastError: null,
|
|
});
|
|
|
|
try {
|
|
await service.markDownstreamDeliveryFailed('delivery-1', 'client offline');
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
|
} else {
|
|
process.env.CMPP_DOWNSTREAM_MAX_RETRIES = previous;
|
|
}
|
|
}
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({
|
|
where: { id: 'delivery-1' },
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
retryCount: 2,
|
|
nextRetryAt: null,
|
|
lastError: 'client offline',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_delivery_failed',
|
|
resource: 'cmpp_downstream_delivery',
|
|
resourceId: 'delivery-1',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('immediately terminates an unrecoverable downstream delivery', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 0,
|
|
retryEnabled: true,
|
|
});
|
|
|
|
await service.markDownstreamDeliveryFailed(
|
|
'delivery-1',
|
|
'历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id',
|
|
'unrecoverable',
|
|
);
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
retryCount: 1,
|
|
nextRetryAt: null,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('does not let the pending timeout scan overwrite a delivery that is already awaiting acknowledgement', async () => {
|
|
const { service, prisma } = createService();
|
|
const awaitingAck = {
|
|
id: 'delivery-1', status: 'awaiting_ack', tenantId: 'tenant-1', applicationId: 'app-1',
|
|
messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 0,
|
|
};
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue(awaitingAck);
|
|
|
|
await expect(service.markDownstreamDeliveryFailed(
|
|
'delivery-1',
|
|
'下游投递排队超过 72 小时,系统自动终止重试',
|
|
'queue_timeout',
|
|
)).resolves.toEqual(awaitingAck);
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('only marks downstream delivery delivered after a successful CMPP_DELIVER_RESP', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.markDownstreamDeliverySent({
|
|
id: 'delivery-1',
|
|
connectionId: 'conn-1',
|
|
sequenceId: '37',
|
|
messageId: '9016479179509871733',
|
|
sentAt: '2026-07-14T03:40:18.030Z',
|
|
ackDeadlineAt: '2026-07-14T03:40:48.030Z',
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
where: { id: 'delivery-1', status: { not: 'delivered' } },
|
|
data: expect.objectContaining({ status: 'awaiting_ack', ackSequenceId: '37', connectionId: 'conn-1' }),
|
|
}));
|
|
expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
create: expect.objectContaining({
|
|
deliveryId: 'delivery-1',
|
|
attemptNo: 1,
|
|
connectionId: 'conn-1',
|
|
sequenceId: '37',
|
|
status: 'awaiting_ack',
|
|
}),
|
|
}));
|
|
|
|
await service.acknowledgeDownstreamDelivery({
|
|
id: 'delivery-1',
|
|
connectionId: 'conn-1',
|
|
sequenceId: '37',
|
|
messageId: '9016479179509871733',
|
|
result: 0,
|
|
acknowledgedAt: '2026-07-14T03:40:18.060Z',
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'delivered', ackResult: 0, deliveredAt: new Date('2026-07-14T03:40:18.060Z') }),
|
|
}));
|
|
expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
update: expect.objectContaining({
|
|
status: 'acknowledged',
|
|
acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'),
|
|
ackResult: 0,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('does not treat Result=0 with Msg_Id=0 as a business acknowledgement', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.acknowledgeDownstreamDelivery({
|
|
id: 'delivery-1',
|
|
connectionId: 'conn-1',
|
|
sequenceId: '91',
|
|
messageId: '0',
|
|
result: 0,
|
|
acknowledgedAt: '2026-07-14T07:07:49.336Z',
|
|
});
|
|
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }),
|
|
}));
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }),
|
|
}));
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'delivered' }),
|
|
}));
|
|
});
|
|
|
|
it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1',
|
|
deliveryType: 'receipt', status: 'awaiting_ack', retryEnabled: false, retryCount: 0,
|
|
});
|
|
|
|
await service.markDownstreamDeliveryFailed('delivery-1', 'CMPP_DELIVER_RESP timeout', 'ack_timeout');
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'unconfirmed', retryCount: 1, nextRetryAt: null }),
|
|
}));
|
|
});
|
|
|
|
it('uses exponential backoff for downstream delivery retries before final failure', async () => {
|
|
const { service, prisma } = createService();
|
|
const previousBase = process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
|
const previousMax = process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
|
const now = Date.UTC(2026, 6, 8, 12, 0, 0);
|
|
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now);
|
|
process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = '60000';
|
|
process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = '600000';
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 2,
|
|
lastError: null,
|
|
});
|
|
|
|
try {
|
|
await service.markDownstreamDeliveryFailed('delivery-1', 'temporary network jitter');
|
|
} finally {
|
|
dateNowSpy.mockRestore();
|
|
if (previousBase === undefined) {
|
|
delete process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
|
} else {
|
|
process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = previousBase;
|
|
}
|
|
if (previousMax === undefined) {
|
|
delete process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
|
} else {
|
|
process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = previousMax;
|
|
}
|
|
}
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({
|
|
where: { id: 'delivery-1' },
|
|
data: expect.objectContaining({
|
|
status: 'pending',
|
|
retryCount: 3,
|
|
nextRetryAt: new Date(now + 240_000),
|
|
lastError: 'temporary network jitter',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('requeues downstream delivery through real gateway control path', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'failed',
|
|
retryCount: 3,
|
|
manualRetryCount: 1,
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
lastError: 'downstream client is not connected',
|
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
|
application: { cmppAccount: '100001' },
|
|
});
|
|
prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'pending',
|
|
retryCount: 0,
|
|
manualRetryCount: 2,
|
|
});
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
|
|
|
await service.requeueDownstreamDelivery('delivery-1');
|
|
|
|
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
|
'/downstream/receipt',
|
|
expect.objectContaining({
|
|
deliveryId: 'delivery-1',
|
|
account: '100001',
|
|
messageId: 'MSG-1',
|
|
}),
|
|
);
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_delivery_requeue',
|
|
resource: 'cmpp_downstream_delivery',
|
|
resourceId: 'delivery-1',
|
|
}),
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: 'delivery-1',
|
|
status: 'failed',
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
}),
|
|
data: expect.objectContaining({
|
|
status: 'manual_requeueing',
|
|
retryCount: 0,
|
|
manualRetryCount: { increment: 1 },
|
|
lastRetriedAt: expect.any(Date),
|
|
acknowledgedAt: null,
|
|
ackResult: null,
|
|
ackMessageId: null,
|
|
deliveredAt: null,
|
|
}),
|
|
}));
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_delivery_requeue',
|
|
detail: expect.objectContaining({
|
|
previousStatus: 'failed',
|
|
previousRetryCount: 3,
|
|
manualRetryCount: 2,
|
|
lastRetriedAt: expect.any(Date),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('rejects manual requeue while downstream acknowledgement is pending', async () => {
|
|
const { service, prisma } = createService();
|
|
service['postGatewayControl'] = jest.fn();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
status: 'awaiting_ack',
|
|
payload: { account: '100001' },
|
|
application: { cmppAccount: '100001' },
|
|
});
|
|
|
|
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled();
|
|
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('terminates a manual requeue when gateway reports it is unrecoverable', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
|
id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1',
|
|
deliveryType: 'receipt', status: 'failed', retryCount: 3, manualRetryCount: 0,
|
|
payload: { account: '100001', messageId: 'MSG-1', receiptStatus: 'delivered' },
|
|
application: { cmppAccount: '100001' },
|
|
}).mockResolvedValueOnce({
|
|
id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1',
|
|
deliveryType: 'receipt', status: 'pending', retryCount: 0, retryEnabled: true,
|
|
});
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({
|
|
sent: false,
|
|
retryable: false,
|
|
reasonCode: 'MISSING_SUBMIT_SEQUENCE_ID',
|
|
errorMessage: '历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id,系统已终止重投',
|
|
});
|
|
|
|
await service.requeueDownstreamDelivery('delivery-1');
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
nextRetryAt: null,
|
|
lastError: expect.stringContaining('MISSING_SUBMIT_SEQUENCE_ID'),
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('supports batch requeue of downstream deliveries', async () => {
|
|
const { service } = createService();
|
|
service.requeueDownstreamDelivery = jest.fn()
|
|
.mockResolvedValueOnce({ id: 'delivery-1' })
|
|
.mockRejectedValueOnce(new Error('Gateway control delivery failed'));
|
|
|
|
await expect(service.batchRequeueDownstreamDeliveries(['delivery-1', 'delivery-2'])).resolves.toEqual({
|
|
total: 2,
|
|
successCount: 1,
|
|
failedCount: 1,
|
|
results: [
|
|
{ id: 'delivery-1', status: 'success' },
|
|
{ id: 'delivery-2', status: 'failed', errorMessage: 'Gateway control delivery failed' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects empty downstream batch requeue selection', async () => {
|
|
const { service } = createService();
|
|
await expect(service.batchRequeueDownstreamDeliveries([])).rejects.toThrow('请选择至少一条下游投递记录');
|
|
});
|
|
|
|
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 },
|
|
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 },
|
|
]);
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' })
|
|
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-2', billingStatus: 'charged' });
|
|
|
|
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 2 });
|
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: { not: null },
|
|
status: { in: ['submitted', 'unknown'] },
|
|
submittedAt: { lte: expect.any(Date) },
|
|
},
|
|
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
|
take: 10000,
|
|
});
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'record-1', status: { in: ['submitted', 'unknown'] } },
|
|
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
|
|
});
|
|
expect(billing.refund).toHaveBeenCalledTimes(2);
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]);
|
|
service.markDownstreamDeliveryFailed = jest.fn().mockResolvedValue({ id: 'delivery-expired', status: 'failed' });
|
|
|
|
await expect(service.markExpiredDownstreamDeliveries(72)).resolves.toEqual({ failed: 1 });
|
|
|
|
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: {
|
|
status: 'pending',
|
|
OR: [
|
|
{ lastRetriedAt: null, createdAt: { lte: expect.any(Date) } },
|
|
{ lastRetriedAt: { lte: expect.any(Date) } },
|
|
],
|
|
},
|
|
}));
|
|
expect(service.markDownstreamDeliveryFailed).toHaveBeenCalledWith(
|
|
'delivery-expired',
|
|
'下游投递排队超过 72 小时,系统自动终止重试',
|
|
'queue_timeout',
|
|
);
|
|
});
|
|
|
|
it('starts the automatic receipt-timeout scan after application startup', async () => {
|
|
jest.useFakeTimers();
|
|
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
const { service } = createService();
|
|
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
|
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
|
|
try {
|
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
|
|
service.onModuleInit();
|
|
await jest.advanceTimersByTimeAsync(60_000);
|
|
expect(scan).toHaveBeenCalledWith({});
|
|
expect(downstreamScan).toHaveBeenCalledWith();
|
|
await service.onModuleDestroy();
|
|
} finally {
|
|
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
|
|
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
});
|