5671 lines
208 KiB
TypeScript
5671 lines
208 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';
|
|
import { DrainageRejection } from './drainage-authorization';
|
|
import { ChannelWordRejection } from './channel-sensitive-routing';
|
|
|
|
function createPrismaMock() {
|
|
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
|
|
const message = {
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
billingUnits: 1,
|
|
unitPrice: 3,
|
|
amountCents: 3,
|
|
status: 'queued',
|
|
queuePriority: 'normal',
|
|
submitId: 'SUB-1',
|
|
gatewayMessageId: 'GW-1',
|
|
channelId: 'channel-1',
|
|
cmppSubmitSequenceId: '101',
|
|
cmppSubmitGroupMessageId: null,
|
|
cmppRegisteredDelivery: true,
|
|
template: { signature: { id: 'sig-1', name: '签名' } },
|
|
};
|
|
const channel = {
|
|
id: 'channel-1',
|
|
code: 'CMPP-A',
|
|
account: 'cmpp-account',
|
|
srcId: '10690000',
|
|
rateLimitPerSecond: 100,
|
|
unitPrice: 3,
|
|
status: 'active',
|
|
carrier: 'mobile',
|
|
sendRegion: '全国',
|
|
gatewayHost: '127.0.0.1',
|
|
gatewayPort: 17890,
|
|
protocol: 'CMPP',
|
|
passwordCipher: 'secret',
|
|
cmppVersion: '3.0',
|
|
config: { serviceId: 'SMS' },
|
|
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
|
};
|
|
const route = {
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
priority: 100,
|
|
status: 'active',
|
|
group: {
|
|
id: 'group-1',
|
|
name: '默认通道组',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: true,
|
|
retryTimeLimitHours: 72,
|
|
retryTimeLimitMinutes: 4320,
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
groupId: 'group-1',
|
|
channelId: 'channel-1',
|
|
carrier: 'mobile',
|
|
priority: 1,
|
|
province: null,
|
|
channel,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const prisma = {
|
|
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
|
|
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
|
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,
|
|
cmppWindowSize: 32,
|
|
queuePriority: 'normal',
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
}),
|
|
},
|
|
smsTemplate: {
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
content: 'hello',
|
|
auditStatus: 'approved',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
|
|
}),
|
|
findFirst: jest.fn().mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
auditStatus: 'approved',
|
|
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
|
}),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
smsSignature: {
|
|
findFirst: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
|
|
},
|
|
smsDrainageInfo: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
drainageDetectionRule: {
|
|
findMany: jest.fn().mockResolvedValue([
|
|
{
|
|
id: 'url',
|
|
code: 'URL',
|
|
name: 'URL',
|
|
category: 'url',
|
|
pattern: '[a-z]+\\.[a-z]+',
|
|
flags: 'giu',
|
|
priority: 1,
|
|
version: 1,
|
|
},
|
|
]),
|
|
},
|
|
smsSendTask: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
|
|
},
|
|
smsBatchTask: {
|
|
create: jest.fn().mockResolvedValue(task),
|
|
findUnique: jest.fn().mockResolvedValue(task),
|
|
findFirst: jest.fn().mockResolvedValue(task),
|
|
findMany: jest.fn(),
|
|
update: jest.fn().mockResolvedValue(task),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
smsApiRequest: {
|
|
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
|
|
findMany: jest.fn(),
|
|
},
|
|
smsMessageRecord: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
findUnique: jest.fn().mockResolvedValue(message),
|
|
findUniqueOrThrow: 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: {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'session-1' }),
|
|
upsert: jest.fn().mockResolvedValue({ id: 'session-1' }),
|
|
},
|
|
smsSubmitRecord: {
|
|
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
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 }))),
|
|
),
|
|
},
|
|
smsDrainageDecision: { create: jest.fn().mockResolvedValue({ id: 'decision-1' }) },
|
|
smsReceiptRecord: {
|
|
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
|
|
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
findMany: jest.fn(),
|
|
},
|
|
smsReceiptAnomaly: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'receipt-anomaly-1', status: 'pending' }),
|
|
},
|
|
smsUplinkMessage: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
|
findMany: jest.fn(),
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'uplink-1', matchStatus: 'matched', matchCandidates: [] }),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
|
},
|
|
smsUplinkMatchCandidate: {
|
|
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findFirst: jest.fn().mockResolvedValue({
|
|
id: 'candidate-1',
|
|
uplinkMessageId: 'uplink-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
matchSource: 'phone_window',
|
|
confidence: 55,
|
|
reason: '手机号 72 小时窗口候选下发 MSG-1',
|
|
status: 'pending',
|
|
application: { id: 'app-1', name: '应用A', cmppAccount: '100001' },
|
|
messageRecord: { id: 'record-1', messageId: 'MSG-1', content: 'hello' },
|
|
uplinkMessage: {
|
|
id: 'uplink-1',
|
|
tenantId: null,
|
|
applicationId: null,
|
|
messageRecordId: null,
|
|
messageId: null,
|
|
channelId: 'channel-1',
|
|
phoneNumber: '13800000001',
|
|
destId: '10690000',
|
|
content: '回复TD',
|
|
matchStatus: 'ambiguous',
|
|
receivedAt: new Date('2026-07-08T12:00:00.000Z'),
|
|
},
|
|
}),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
|
|
},
|
|
cmppDownstreamDelivery: {
|
|
create: jest
|
|
.fn()
|
|
.mockImplementation(({ data }) =>
|
|
Promise.resolve({ id: 'delivery-1', ...data, createdAt: new Date(), updatedAt: new Date() }),
|
|
),
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 0,
|
|
manualRetryCount: 0,
|
|
status: 'failed',
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
lastError: null,
|
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
|
application: { cmppAccount: '100001' },
|
|
}),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
update: jest.fn().mockImplementation(({ data }) =>
|
|
Promise.resolve({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
...data,
|
|
}),
|
|
),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
cmppDownstreamDeliveryAttempt: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'delivery-attempt-1' }),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
upstreamReceiptInbox: {
|
|
upsert: jest.fn().mockImplementation(({ create }) =>
|
|
Promise.resolve({
|
|
id: 'receipt-inbox-1',
|
|
attemptCount: 0,
|
|
receivedAt: new Date(),
|
|
...create,
|
|
}),
|
|
),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'receipt-inbox-1', ...data })),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
gatewaySubmitDeadLetter: {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'dead-1' }),
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'dead-1',
|
|
tenantId: 'tenant-1',
|
|
channelId: 'channel-1',
|
|
streamMessageId: '1710000000000-0',
|
|
submitId: 'SUB-1',
|
|
messageId: 'MSG-1',
|
|
status: 'pending',
|
|
manualRetryCount: 0,
|
|
commandPayload: {
|
|
schemaVersion: 'v1',
|
|
messageType: 'SubmitCommand',
|
|
traceId: 'trace-1',
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
createdAt: '2026-07-08T12:00:00.000Z',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
submitId: 'SUB-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
signature: '签名',
|
|
templateId: 'tpl-1',
|
|
billingUnits: 1,
|
|
queuePriority: 'normal',
|
|
route: { channelCode: 'CMPP-A', cmppAccountCode: 'account-a', priority: 0 },
|
|
cmpp: { serviceId: 'SMS', srcId: '10690000', registeredDelivery: 1, msgFmt: 8 },
|
|
upstream: {
|
|
gatewayHost: '127.0.0.1',
|
|
gatewayPort: 17890,
|
|
account: 'account-a',
|
|
passwordCipher: 'secret',
|
|
cmppVersion: '3.0',
|
|
},
|
|
retry: { attempt: 0, maxAttempts: 1 },
|
|
},
|
|
}),
|
|
update: jest.fn().mockResolvedValue({
|
|
id: 'dead-1',
|
|
tenantId: 'tenant-1',
|
|
streamMessageId: '1710000000000-0',
|
|
submitId: 'SUB-1',
|
|
messageId: 'MSG-1',
|
|
}),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
gatewayDownstreamRecoveryStatus: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
upsert: jest.fn().mockResolvedValue({
|
|
id: 'recover-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
account: '100001',
|
|
gatewayInstanceId: 'gateway-a',
|
|
state: 'waiting_connection',
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
|
attemptCount: 2,
|
|
failureCategory: 'client_disconnected',
|
|
nextRetryAt: new Date('2026-07-08T12:10:00.000Z'),
|
|
lastError: 'downstream client is not connected',
|
|
}),
|
|
},
|
|
gatewaySubmitOutbox: {
|
|
create: jest.fn().mockResolvedValue({ id: 'outbox-1', submitId: 'SUB-1', status: 'pending' }),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
cmppInboundSubmissionInbox: {
|
|
create: jest.fn().mockResolvedValue({ id: 'inbox-1' }),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
},
|
|
smsApplicationDailyReservation: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockResolvedValue({ id: 'daily-reservation-1' }),
|
|
},
|
|
phoneFrequencyReservation: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockResolvedValue({ id: 'frequency-reservation-1' }),
|
|
},
|
|
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' }),
|
|
settleFrozenCharge: jest.fn().mockResolvedValue({ id: 'tx-charge' }),
|
|
refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }),
|
|
} as unknown as BillingService;
|
|
const riskReview = {
|
|
guardNightSending: jest.fn().mockResolvedValue(new Set()),
|
|
pendingNightContinuations: jest.fn().mockResolvedValue([]),
|
|
evaluateTask: jest.fn().mockResolvedValue({
|
|
status: 'approved',
|
|
reason: null,
|
|
task: { id: 'risk-task-1' },
|
|
}),
|
|
aggregateTemplateMismatch: jest.fn().mockResolvedValue({
|
|
id: 'review-task-1',
|
|
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
|
}),
|
|
} as unknown as RiskReviewService;
|
|
const phoneFrequency = {
|
|
reserve: jest.fn().mockResolvedValue(new Map()),
|
|
};
|
|
const service = new SendChainService(prisma as never, billing, riskReview, phoneFrequency as never, openApi as never);
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
|
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
|
service['getSendQueue'] = jest.fn().mockReturnValue({ add: jest.fn().mockResolvedValue(undefined) });
|
|
return { service, prisma, billing, riskReview, phoneFrequency };
|
|
}
|
|
|
|
describe('SendChainService', () => {
|
|
it('recovers non-CMPP channel-word finalization without pushing a receipt', async () => {
|
|
const { service, prisma } = createService();
|
|
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{ ...message, channelWordFinalizationPending: true, batchTask: { sourceType: 'client' } },
|
|
]);
|
|
service['releaseMessageReservation'] = jest.fn();
|
|
service['recordCmppFailureReceipt'] = jest.fn();
|
|
service['refreshTaskProgress'] = jest.fn();
|
|
await service.recoverDrainageFailureReceipts();
|
|
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
|
|
expect(service['refreshTaskProgress']).toHaveBeenCalledWith('task-1');
|
|
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
|
|
where: { id: 'record-1' },
|
|
data: { channelWordFinalizationPending: false },
|
|
});
|
|
});
|
|
it.each(['cmpp', 'client', 'http'])(
|
|
'fails an all-hit ordinary route without supplier submit (%s)',
|
|
async (sourceType) => {
|
|
const { service, prisma } = createService();
|
|
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...message, batchTask: { id: 'task-1', sourceType } });
|
|
service['selectChannelForMessage'] = jest.fn().mockRejectedValue(new ChannelWordRejection());
|
|
service['recordCmppFailureReceipt'] = jest.fn();
|
|
service['releaseMessageReservation'] = jest.fn();
|
|
service['refreshTaskProgress'] = jest.fn();
|
|
await service.processSendJob({ messageRecordId: 'record-1' });
|
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
|
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
|
|
if (sourceType === 'cmpp')
|
|
expect(service['recordCmppFailureReceipt']).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'CSW',
|
|
'可用通道均命中通道敏感词',
|
|
);
|
|
else expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ channelWordFinalizationPending: true, drainageReceiptPending: false }),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
it('recovers channel-word delivery intent from an existing receipt and keeps pending on failure', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
|
|
const message = {
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
cmppRegisteredDelivery: true,
|
|
cmppSubmitSequenceId: '101',
|
|
};
|
|
service['queueAndTryDownstreamDelivery'] = jest
|
|
.fn()
|
|
.mockRejectedValueOnce(Error('persistence failed'))
|
|
.mockResolvedValue({ id: 'delivery' });
|
|
await expect(service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词')).rejects.toThrow(
|
|
'persistence failed',
|
|
);
|
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: { channelWordFinalizationPending: false } }),
|
|
);
|
|
await service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词');
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
|
|
where: { id: 'record-1' },
|
|
data: { channelWordFinalizationPending: false },
|
|
});
|
|
});
|
|
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
|
|
service['queueAndTryDownstreamDelivery'] = jest.fn().mockResolvedValue({ id: 'delivery' });
|
|
service['refreshTaskProgress'] = jest.fn().mockResolvedValue({});
|
|
await service['recordCmppFailureReceipt'](
|
|
{
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
cmppSubmitSequenceId: '101',
|
|
},
|
|
'DRN',
|
|
'未报备',
|
|
);
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
|
|
expect(service['queueAndTryDownstreamDelivery']).toHaveBeenCalledWith(
|
|
expect.objectContaining({ queueCmppDelivery: true, receiptDedupeKey: 'receipt:record-1' }),
|
|
);
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
|
|
where: { id: 'record-1' },
|
|
data: { drainageReceiptPending: false },
|
|
});
|
|
});
|
|
it('keeps durable recovery pending when drainage receipt intent persistence fails', async () => {
|
|
const { service, prisma } = createService();
|
|
service['queueAndTryDownstreamDelivery'] = jest.fn().mockRejectedValue(new Error('queue persistence unavailable'));
|
|
await expect(
|
|
service['recordCmppFailureReceipt'](
|
|
{
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
cmppSubmitSequenceId: '101',
|
|
},
|
|
'DRN',
|
|
'未报备',
|
|
),
|
|
).rejects.toThrow('queue persistence unavailable');
|
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: { drainageReceiptPending: false } }),
|
|
);
|
|
});
|
|
it('does not push any drainage rejection receipt for a non-CMPP submission', async () => {
|
|
const { service, prisma } = createService();
|
|
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
|
...message,
|
|
batchTask: { id: 'task-1', sourceType: 'client' },
|
|
});
|
|
service['selectChannelForMessage'] = jest
|
|
.fn()
|
|
.mockRejectedValue(new DrainageRejection('DRAINAGE_NOT_REGISTERED', '未报备'));
|
|
service['recordCmppFailureReceipt'] = jest.fn();
|
|
service['releaseMessageReservation'] = jest.fn();
|
|
service['refreshTaskProgress'] = jest.fn();
|
|
await service.processSendJob({ messageRecordId: 'record-1' });
|
|
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
errorCode: 'DRAINAGE_NOT_REGISTERED',
|
|
drainageReceiptPending: false,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
|
|
const { service, prisma, riskReview, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000001', '13800000002'],
|
|
sourceIp: '127.0.0.1',
|
|
userAgent: 'jest',
|
|
});
|
|
|
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({ phones: ['13800000001', '13800000002'] }),
|
|
);
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ phoneTotal: 2, status: 'ready', progressTotal: 2, auditStatus: 'approved' }),
|
|
});
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
phoneNumber: '13800000001',
|
|
status: 'queued',
|
|
billingUnits: 1,
|
|
amountCents: 3,
|
|
queuePriority: 'normal',
|
|
}),
|
|
expect.objectContaining({
|
|
phoneNumber: '13800000002',
|
|
status: 'queued',
|
|
billingUnits: 1,
|
|
amountCents: 3,
|
|
queuePriority: 'normal',
|
|
}),
|
|
]),
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' }));
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
|
});
|
|
|
|
it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]);
|
|
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
|
billingUnitsPerMessage: 1,
|
|
totalBillingUnits: 1,
|
|
unitPrice: 3,
|
|
amountCents: 3,
|
|
});
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '23800000002', '13800000002'],
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
|
expect.objectContaining({
|
|
phoneNumber: '23800000002',
|
|
status: 'submit_failed',
|
|
submitStatus: 'rejected',
|
|
errorCode: 'INVALID_PHONE',
|
|
amountCents: 0,
|
|
}),
|
|
expect.objectContaining({
|
|
phoneNumber: '13800000002',
|
|
status: 'submit_failed',
|
|
submitStatus: 'rejected',
|
|
errorCode: 'GLOBAL_BLACKLIST',
|
|
amountCents: 0,
|
|
}),
|
|
]),
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
|
});
|
|
|
|
it('rejects only phones that hit application frequency rules and excludes them from billing', async () => {
|
|
const { service, prisma, billing, phoneFrequency } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
phoneFrequency.reserve.mockResolvedValue(
|
|
new Map([
|
|
[
|
|
'13800000002',
|
|
{
|
|
code: 'PHONE_FREQUENCY_LIMIT',
|
|
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
|
billingUnitsPerMessage: 1,
|
|
totalBillingUnits: 1,
|
|
unitPrice: 3,
|
|
amountCents: 3,
|
|
});
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000002'],
|
|
});
|
|
|
|
expect(phoneFrequency.reserve).toHaveBeenCalledWith('tenant-1', 'app-1', ['13800000001', '13800000002'], 'client');
|
|
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 }));
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
|
expect.objectContaining({
|
|
phoneNumber: '13800000002',
|
|
status: 'submit_failed',
|
|
submitStatus: 'rejected',
|
|
errorCode: 'PHONE_FREQUENCY_LIMIT',
|
|
amountCents: 0,
|
|
}),
|
|
]),
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
|
});
|
|
|
|
it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => {
|
|
const { service, prisma, riskReview, phoneFrequency } = createService();
|
|
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
|
status: 'pending_review',
|
|
reason: '命中人工审核规则',
|
|
task: { id: 'review-task-1' },
|
|
});
|
|
phoneFrequency.reserve.mockResolvedValue(
|
|
new Map([
|
|
[
|
|
'13800000001',
|
|
{
|
|
code: 'PHONE_FREQUENCY_LIMIT',
|
|
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
});
|
|
|
|
expect(prisma.smsSendTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'review-task-1' },
|
|
data: expect.objectContaining({
|
|
status: 'rejected',
|
|
riskDecision: 'block',
|
|
reviewReason: null,
|
|
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
|
}),
|
|
});
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
status: 'rejected',
|
|
auditStatus: 'rejected',
|
|
reviewReason: null,
|
|
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('persists the review task id on every message waiting for manual review', async () => {
|
|
const { service, prisma, riskReview } = createService();
|
|
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
|
status: 'pending_review',
|
|
reason: '命中人工审核规则',
|
|
task: { id: 'review-task-1' },
|
|
});
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [
|
|
expect.objectContaining({
|
|
phoneNumber: '13800000001',
|
|
status: 'pending_review',
|
|
reviewTaskId: 'review-task-1',
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000002'],
|
|
}),
|
|
).rejects.toThrow('应用当日发送上限1条');
|
|
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.createMany).not.toHaveBeenCalled();
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('recognizes an approved template for public HTTP content and reads back the api task', async () => {
|
|
const { service, prisma, riskReview } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
|
id: 'tpl-http',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: '【签名】验证码${code}',
|
|
auditStatus: 'approved',
|
|
signatureId: 'sig-1',
|
|
signature: { id: 'sig-1', auditStatus: 'approved' },
|
|
});
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-http',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: '【签名】验证码${code}',
|
|
auditStatus: 'approved',
|
|
signatureId: 'sig-1',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
|
|
await service.createHttpBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: '【签名】验证码123456',
|
|
phones: ['13800000001'],
|
|
sourceIp: '127.0.0.1',
|
|
clientMessageId: 'client-http-1',
|
|
});
|
|
|
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
templateId: 'tpl-http',
|
|
variables: { code: '123456' },
|
|
}),
|
|
);
|
|
expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: 'task-1', sourceType: 'api' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('persists the unique longest approved drainage URL match on new message records', async () => {
|
|
const { service, prisma } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
auditStatus: 'approved',
|
|
content: '【签名】详情请访问 https://a.example/landing',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
|
|
});
|
|
prisma.smsDrainageInfo.findMany.mockResolvedValue([
|
|
{ id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') },
|
|
{
|
|
id: 'drain-long',
|
|
url: 'https://a.example/landing',
|
|
auditStatus: 'approved',
|
|
updatedAt: new Date('2026-07-02'),
|
|
},
|
|
]);
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: '【签名】详情请访问 https://a.example/landing',
|
|
phones: ['13800000001'],
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ signatureId: 'sig-1', drainageInfoId: 'drain-long' })],
|
|
});
|
|
});
|
|
|
|
it('rejects a task when the submitted content no longer matches the selected approved template', async () => {
|
|
const { service, prisma, riskReview } = createService();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
content: '【签名】验证码${code}',
|
|
auditStatus: 'approved',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: '【签名】被篡改的正文',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('短信内容与选定的审核模板不匹配');
|
|
|
|
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a new task that selects a deleted template', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
content: 'hello',
|
|
auditStatus: 'deleted',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用');
|
|
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects free content without an approved leading signature', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
status: 'active',
|
|
interfaceEnabled: true,
|
|
customerUnitPrice: 3,
|
|
queuePriority: 'normal',
|
|
templateMismatchMode: 'direct_send',
|
|
});
|
|
prisma.smsSignature.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: '没有签名的自由内容',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头');
|
|
});
|
|
|
|
it('allows signed free content only when the application explicitly uses direct send', async () => {
|
|
const { service, prisma } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
status: 'active',
|
|
interfaceEnabled: true,
|
|
customerUnitPrice: 3,
|
|
queuePriority: 'normal',
|
|
templateMismatchMode: 'direct_send',
|
|
});
|
|
prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' });
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: '【签名】允许直接发送的自由内容',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).resolves.toBeDefined();
|
|
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ signatureId: 'sig-1' })],
|
|
});
|
|
});
|
|
|
|
it.each(['pending', 'rejected'])(
|
|
'does not block a matched %s drainage URL and still preserves the matched resource',
|
|
async (auditStatus) => {
|
|
const { service, prisma, riskReview } = createService();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
content: '【签名】详情 https://blocked.example',
|
|
auditStatus: 'approved',
|
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
|
});
|
|
prisma.smsDrainageInfo.findMany.mockResolvedValue([
|
|
{ id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') },
|
|
]);
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: '【签名】详情 https://blocked.example',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).resolves.toBeDefined();
|
|
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ status: 'ready', rejectReason: null }),
|
|
});
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [
|
|
expect.objectContaining({
|
|
drainageInfoId: 'drain-blocked',
|
|
status: 'queued',
|
|
errorMessage: undefined,
|
|
}),
|
|
],
|
|
});
|
|
expect(riskReview.evaluateTask).toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
const scheduledAt = new Date(Date.now() + 60_000).toISOString();
|
|
|
|
await service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
sendMode: 'scheduled',
|
|
scheduledAt,
|
|
});
|
|
|
|
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ status: 'scheduled', scheduledAt: expect.any(Date) }),
|
|
});
|
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ status: 'scheduled' })],
|
|
});
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
|
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([
|
|
{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' },
|
|
]);
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
|
|
await expect(service.dispatchDueScheduledTasks(new Date(Date.now() + 120_000))).resolves.toEqual({
|
|
dispatched: 1,
|
|
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
|
});
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
|
data: { status: 'queued' },
|
|
});
|
|
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({ id: 'task-1', status: 'scheduled' }),
|
|
data: { status: 'scheduled_dispatching' },
|
|
});
|
|
});
|
|
|
|
it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
const dueTask = {
|
|
id: 'task-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
status: 'scheduled',
|
|
scheduledAt: new Date(Date.now() - 1_000),
|
|
updatedAt: new Date(Date.now() - 1_000),
|
|
};
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]);
|
|
prisma.smsBatchTask.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
|
|
const results = await Promise.all([
|
|
service.dispatchDueScheduledTasks(new Date()),
|
|
service.dispatchDueScheduledTasks(new Date()),
|
|
]);
|
|
|
|
expect(results.map((item) => item.dispatched).sort()).toEqual([0, 1]);
|
|
expect(billing.freeze).toHaveBeenCalledTimes(1);
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('recovers a stale claimed task without freezing its balance twice', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
const now = new Date();
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([
|
|
{
|
|
id: 'task-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
status: 'scheduled_dispatching',
|
|
scheduledAt: new Date(now.getTime() - 300_000),
|
|
updatedAt: new Date(now.getTime() - 300_000),
|
|
},
|
|
]);
|
|
prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
|
|
await expect(service.dispatchDueScheduledTasks(now)).resolves.toEqual({
|
|
dispatched: 1,
|
|
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
|
});
|
|
|
|
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({
|
|
id: 'task-1',
|
|
status: 'scheduled_dispatching',
|
|
updatedAt: { lt: expect.any(Date) },
|
|
}),
|
|
data: { status: 'scheduled_recovering' },
|
|
});
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([
|
|
{
|
|
id: 'task-free',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
status: 'scheduled',
|
|
},
|
|
]);
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
|
|
|
|
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
|
dispatched: 0,
|
|
results: [{ taskId: 'task-free', status: 'retrying', reason: 'Redis unavailable' }],
|
|
});
|
|
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'task-free' },
|
|
data: { status: 'scheduled_dispatching', rejectReason: '调度将在超时后恢复:Redis unavailable' },
|
|
});
|
|
});
|
|
|
|
it('automatically scans and dispatches due scheduled tasks after application startup', async () => {
|
|
jest.useFakeTimers();
|
|
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
|
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
|
const { service } = createService();
|
|
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
|
|
try {
|
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
|
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
|
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
|
|
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
|
|
service.onModuleInit();
|
|
await jest.advanceTimersByTimeAsync(1_000);
|
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
await service.onModuleDestroy();
|
|
} finally {
|
|
if (previousReceiptEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousReceiptEnabled;
|
|
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
|
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
|
|
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
|
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('cancels scheduled tasks before dispatch', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', status: 'scheduled' });
|
|
|
|
await service.cancelBatchTask('task-1');
|
|
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
|
data: { status: 'canceled', errorMessage: '定时任务已取消' },
|
|
});
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'task-1' },
|
|
data: { status: 'canceled', canceledAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it('lists only client-created batch tasks for task progress', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-client', tenantId: 'tenant-1', sourceType: 'client' }]);
|
|
prisma.smsMessageRecord.groupBy.mockResolvedValue([]);
|
|
|
|
await service.listBatchTasks('tenant-1', 'queued');
|
|
|
|
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('coalesces concurrent batch progress refreshes and keeps a trailing refresh', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.$executeRaw.mockResolvedValue(0);
|
|
let resolveFirst: ((value: Array<{ status: string; _count: { _all: number } }>) => void) | undefined;
|
|
prisma.smsMessageRecord.groupBy
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveFirst = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValue([{ status: 'delivered', _count: { _all: 2 } }]);
|
|
|
|
const first = service['submission'].refreshTaskProgress('task-1');
|
|
await Promise.resolve();
|
|
const second = service['submission'].refreshTaskProgress('task-1');
|
|
|
|
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledTimes(1);
|
|
resolveFirst?.([{ status: 'delivered', _count: { _all: 1 } }]);
|
|
await Promise.all([first, second]);
|
|
|
|
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledTimes(2);
|
|
expect(prisma.smsBatchTask.update).toHaveBeenLastCalledWith({
|
|
where: { id: 'task-1' },
|
|
data: expect.objectContaining({ progressTotal: 2, successTotal: 2, status: 'finished' }),
|
|
});
|
|
});
|
|
|
|
it('updates a known single-message CMPP task without grouping all message states', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service['submission'].refreshTaskProgress('task-cmpp-1', 'submit_queued');
|
|
|
|
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'task-cmpp-1', sourceType: 'cmpp', phoneTotal: 1 },
|
|
data: {
|
|
progressTotal: 1,
|
|
submittedTotal: 1,
|
|
successTotal: 0,
|
|
failedTotal: 0,
|
|
unknownTotal: 0,
|
|
timeoutTotal: 0,
|
|
status: 'sending',
|
|
},
|
|
});
|
|
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes callback progress for a single-message CMPP task in one direct SQL update', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.$executeRaw.mockResolvedValue(1);
|
|
|
|
await service['submission'].refreshTaskProgress('task-cmpp-callback');
|
|
|
|
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
|
|
expect(prisma.smsBatchTask.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not expose CMPP internal tasks through client task detail or messages', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(service.getBatchTask('task-cmpp', 'tenant-1', 'client')).rejects.toThrow('SMS batch task not found');
|
|
await expect(service.listClientTaskMessages('task-cmpp', 'tenant-1')).rejects.toThrow('SMS batch task not found');
|
|
expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'task-cmpp', tenantId: 'tenant-1', sourceType: 'client' },
|
|
}),
|
|
);
|
|
expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('paginates the real phone list for an admin batch task', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', sourceType: 'client' });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{ id: 'record-1', phoneNumber: '13800000001', province: '上海', carrier: 'mobile', status: 'delivered' },
|
|
]);
|
|
prisma.smsMessageRecord.count.mockResolvedValue(21);
|
|
|
|
await expect(service.listAdminBatchTaskMessages('task-1', '138', 2, 20)).resolves.toEqual({
|
|
items: [expect.objectContaining({ phoneNumber: '13800000001' })],
|
|
total: 21,
|
|
page: 2,
|
|
pageSize: 20,
|
|
});
|
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', phoneNumber: { contains: '138' } },
|
|
select: {
|
|
id: true,
|
|
phoneNumber: true,
|
|
province: true,
|
|
carrier: true,
|
|
status: true,
|
|
},
|
|
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
|
|
skip: 20,
|
|
take: 20,
|
|
});
|
|
});
|
|
|
|
it('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
auditStatus: 'deleted',
|
|
signature: { id: 'sig-1', auditStatus: 'approved' },
|
|
});
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([
|
|
{
|
|
id: 'task-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
status: 'scheduled',
|
|
},
|
|
]);
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
|
|
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
|
dispatched: 1,
|
|
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
|
});
|
|
|
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ relatedId: 'task-1' }));
|
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
|
});
|
|
|
|
it('still blocks scheduled dispatch when the persisted template signature is no longer approved', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
auditStatus: 'deleted',
|
|
signature: { id: 'sig-1', auditStatus: 'deleted' },
|
|
});
|
|
prisma.smsBatchTask.findMany.mockResolvedValue([
|
|
{
|
|
id: 'task-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
status: 'scheduled',
|
|
},
|
|
]);
|
|
|
|
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
|
dispatched: 0,
|
|
results: [{ taskId: 'task-1', status: 'failed', reason: '短信签名未审核通过' }],
|
|
});
|
|
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
|
service['refreshTaskProgress'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await service.terminateBatchTask('task-1');
|
|
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { batchTaskId: 'task-1', status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } },
|
|
data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' },
|
|
});
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
|
where: { id: 'task-1' },
|
|
data: { status: 'canceled', canceledAt: expect.any(Date), rejectReason: '运营终止任务' },
|
|
});
|
|
});
|
|
|
|
it('blocks sending when enterprise certification is not approved', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' });
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('企业认证未通过,不能发送短信');
|
|
});
|
|
|
|
it('blocks sending when application interface is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
customerUnitPrice: 3,
|
|
queuePriority: 'normal',
|
|
});
|
|
|
|
await expect(
|
|
service.createBatchTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
content: 'hello',
|
|
phones: ['13800000001'],
|
|
}),
|
|
).rejects.toThrow('短信应用接口未开通,不能发送短信');
|
|
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await expect(
|
|
service.authenticateInboundApplication({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
remoteIp: '127.0.0.1',
|
|
version: 'cmpp30',
|
|
requestedVersion: 48,
|
|
}),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
account: '100001',
|
|
enterpriseCode: 'SP0001',
|
|
maxConnections: 2,
|
|
windowSize: 32,
|
|
status: 'authenticated',
|
|
}),
|
|
);
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
action: 'cmpp_connection.connect_requested',
|
|
resource: 'cmpp_downstream_connection',
|
|
resourceId: 'app-1',
|
|
ipAddress: '127.0.0.1',
|
|
detail: expect.objectContaining({
|
|
result: 'authenticated',
|
|
request: expect.objectContaining({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
version: 'cmpp30',
|
|
requestedVersion: 48,
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('rejects Gateway authentication when application interface is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
secretHash: 'secret-hash',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
|
});
|
|
await expect(
|
|
service.authenticateInboundApplication({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
remoteIp: '127.0.0.1',
|
|
}),
|
|
).rejects.toThrow('CMPP interface is disabled for this application');
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
ipAddress: '127.0.0.1',
|
|
detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('audits an unknown Gateway authentication account with its source IP', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsApplication.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.authenticateInboundApplication({
|
|
account: 'ATTACKER',
|
|
authSource: 'invalid-auth-source',
|
|
timestamp: 120000000,
|
|
remoteIp: '203.0.113.9',
|
|
version: 'cmpp30',
|
|
requestedVersion: 48,
|
|
}),
|
|
).rejects.toThrow('CMPP account is invalid or disabled');
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: undefined,
|
|
resourceId: 'ATTACKER',
|
|
ipAddress: '203.0.113.9',
|
|
detail: expect.objectContaining({
|
|
result: 'failed',
|
|
request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('accepts Submit after bind and emits an auditable REJECTD receipt when the interface was 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,
|
|
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',
|
|
status: 'active',
|
|
interfaceEnabled: false,
|
|
downstreamReceiptRetryEnabled: true,
|
|
downstreamUplinkRetryEnabled: true,
|
|
httpConfig: { enabled: false },
|
|
});
|
|
|
|
await expect(
|
|
service.submitInboundMessage({
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
sequenceId: 701,
|
|
remoteIp: '127.0.0.1',
|
|
}),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
accepted: true,
|
|
messageRecordId: 'record-1',
|
|
status: 'accepted',
|
|
}),
|
|
);
|
|
|
|
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'record-1' },
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
receiptStatus: 'undelivered',
|
|
receiptRawStatus: 'REJECTD',
|
|
errorCode: 'INTERFACE',
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }),
|
|
}),
|
|
);
|
|
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
|
'/downstream/receipt',
|
|
expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }),
|
|
);
|
|
});
|
|
|
|
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',
|
|
}),
|
|
undefined,
|
|
);
|
|
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.smsApplication.findFirst).toHaveBeenCalledTimes(1);
|
|
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', {
|
|
messageRecordId: 'record-1',
|
|
queuePriority: 'normal',
|
|
});
|
|
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', {
|
|
messageRecordId: 'record-1',
|
|
queuePriority: 'normal',
|
|
});
|
|
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();
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
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(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
|
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(prisma.cmppSubmitSession.findUnique).toHaveBeenCalledWith({
|
|
where: { sessionNo: 'OPEN-channel-1' },
|
|
select: { id: true },
|
|
});
|
|
expect(prisma.cmppSubmitSession.upsert).not.toHaveBeenCalled();
|
|
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
|
});
|
|
|
|
it('shadow-writes the durable submit Outbox without replacing the direct stream path', async () => {
|
|
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
|
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
|
|
delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
try {
|
|
const { service, prisma } = createService();
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await service.processSendJob({ messageRecordId: 'record-1' });
|
|
|
|
expect(prisma.gatewaySubmitOutbox.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
messageRecordId: 'record-1',
|
|
channelId: 'channel-1',
|
|
payload: expect.objectContaining({ messageType: 'SubmitCommand', messageId: 'MSG-1' }),
|
|
}),
|
|
});
|
|
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
if (previousShadow === undefined) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
|
|
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
|
}
|
|
});
|
|
|
|
it('uses only the durable Outbox when formal publishing is enabled', async () => {
|
|
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
|
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
|
|
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = 'true';
|
|
try {
|
|
const { service, prisma } = createService();
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await service.processSendJob({ messageRecordId: 'record-1' });
|
|
|
|
expect(prisma.gatewaySubmitOutbox.create).toHaveBeenCalledTimes(1);
|
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previousShadow === undefined) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
|
|
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
|
}
|
|
});
|
|
|
|
it('plans routes once and bulk-creates Submit records and Outbox rows for a Worker batch', async () => {
|
|
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = 'true';
|
|
try {
|
|
const { service, prisma } = createService();
|
|
const base = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
|
const messages = [
|
|
{
|
|
...base,
|
|
id: 'record-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
amountCents: 3n,
|
|
carrier: null,
|
|
province: null,
|
|
batchTask: { sourceType: 'cmpp', phoneTotal: 1 },
|
|
},
|
|
{
|
|
...base,
|
|
id: 'record-2',
|
|
batchTaskId: 'task-2',
|
|
messageId: 'MSG-2',
|
|
phoneNumber: '13800000002',
|
|
amountCents: 3n,
|
|
carrier: null,
|
|
province: null,
|
|
batchTask: { sourceType: 'cmpp', phoneTotal: 1 },
|
|
},
|
|
];
|
|
const channel = {
|
|
id: 'channel-1',
|
|
code: 'CMPP-A',
|
|
account: 'cmpp-account',
|
|
srcId: '10690000',
|
|
rateLimitPerSecond: 100,
|
|
unitPrice: 3n,
|
|
status: 'active',
|
|
carrier: 'mobile',
|
|
sendRegion: '全国',
|
|
gatewayHost: '127.0.0.1',
|
|
gatewayPort: 17890,
|
|
passwordCipher: 'secret',
|
|
cmppVersion: '3.0',
|
|
config: { serviceId: 'SMS' },
|
|
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
|
reportTasks: [{ signatureId: 'sig-1', carrier: 'mobile', approvalScope: 'carrier_specific' }],
|
|
};
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue(messages);
|
|
prisma.channelRouteRule.findMany.mockResolvedValue([
|
|
{
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
carrier: 'mobile',
|
|
groupId: 'group-1',
|
|
group: {
|
|
name: '默认通道组',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
groupId: 'group-1',
|
|
channelId: 'channel-1',
|
|
carrier: 'mobile',
|
|
province: null,
|
|
priority: 1,
|
|
weight: 1,
|
|
isBackup: false,
|
|
channel,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
const gatewaySubmit = (service as any).submission.gatewaySubmit;
|
|
const result = await gatewaySubmit.processSendJobBatch([
|
|
{ messageRecordId: 'record-1' },
|
|
{ messageRecordId: 'record-2' },
|
|
]);
|
|
|
|
expect(prisma.channelRouteRule.findMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsSubmitRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1' }),
|
|
expect.objectContaining({ messageRecordId: 'record-2', channelId: 'channel-1' }),
|
|
]),
|
|
});
|
|
expect(prisma.gatewaySubmitOutbox.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
messageRecordId: 'record-1',
|
|
payload: expect.objectContaining({ messageId: 'MSG-1' }),
|
|
}),
|
|
expect.objectContaining({
|
|
messageRecordId: 'record-2',
|
|
payload: expect.objectContaining({ messageId: 'MSG-2' }),
|
|
}),
|
|
]),
|
|
});
|
|
expect(result.get('record-1')).toEqual(expect.objectContaining({ submitted: true }));
|
|
expect(result.get('record-2')).toEqual(expect.objectContaining({ submitted: true }));
|
|
} finally {
|
|
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
|
}
|
|
});
|
|
|
|
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',
|
|
});
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await service.processSendJob({ messageRecordId: 'record-1' });
|
|
|
|
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
|
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], id: 'item-2', channelId: backup.id, priority: 2, channel: backup }],
|
|
},
|
|
});
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
|
expect.objectContaining({ submitted: true, channelId: backup.id }),
|
|
);
|
|
expect(prisma.channelSignatureReportTask.findMany).not.toHaveBeenCalled();
|
|
expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-1');
|
|
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',
|
|
signatureId: 'sig-1',
|
|
phoneNumber: '13800000001',
|
|
}),
|
|
).rejects.toThrow('企业应用未配置对应运营商通道组');
|
|
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
|
where: { id: 'record-1' },
|
|
data: { carrier: 'mobile', province: '山东' },
|
|
});
|
|
});
|
|
|
|
it('returns from the durable CMPP Inbox fast path before risk, billing, or queue publication', async () => {
|
|
const previous = process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
process.env.CMPP_INBOUND_FAST_PATH_ENABLED = 'true';
|
|
try {
|
|
const { service, prisma, billing, riskReview, phoneFrequency } = createService();
|
|
service.enqueueBatchTask = jest.fn();
|
|
prisma.$queryRaw.mockImplementationOnce((query) => {
|
|
const payloadHash = query.values.find(
|
|
(value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value),
|
|
);
|
|
return Promise.resolve([
|
|
{
|
|
validationError: null,
|
|
payloadHash,
|
|
response: {
|
|
accepted: true,
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
taskId: '',
|
|
messageId: 'MSG-fast',
|
|
messageRecordId: '',
|
|
status: 'accepted_pending',
|
|
phoneCount: 2,
|
|
messages: [],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
const result = await service.submitInboundMessage({
|
|
requestId: 'cmpp-inbound:test-fast-path',
|
|
account: '100001',
|
|
phoneNumbers: ['13800000001', '13900000002'],
|
|
content: 'hello',
|
|
sequenceId: 777,
|
|
remoteIp: '127.0.0.1',
|
|
});
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
accepted: true,
|
|
status: 'accepted_pending',
|
|
phoneCount: 2,
|
|
}),
|
|
);
|
|
expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled();
|
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
|
const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' ');
|
|
expect(sql).toContain('INSERT INTO "CmppInboundSubmissionInbox"');
|
|
expect(sql).toContain('JOIN "Tenant"');
|
|
expect(sql).toContain('ON CONFLICT ("requestKey") DO NOTHING');
|
|
expect(sql).toContain("'messageId', ");
|
|
expect(sql).toContain('::text');
|
|
expect(sql).toContain("'phoneCount', ");
|
|
expect(sql).toContain('::integer');
|
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
|
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
|
|
expect(phoneFrequency.reserve).not.toHaveBeenCalled();
|
|
expect(billing.freeze).not.toHaveBeenCalled();
|
|
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
else process.env.CMPP_INBOUND_FAST_PATH_ENABLED = previous;
|
|
}
|
|
});
|
|
|
|
it('returns the stored SubmitResp for an idempotent CMPP Inbox retry', async () => {
|
|
const previous = process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
process.env.CMPP_INBOUND_FAST_PATH_ENABLED = 'true';
|
|
try {
|
|
const { service, prisma } = createService();
|
|
const storedResponse = {
|
|
accepted: true,
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
taskId: '',
|
|
messageId: 'MSG-stable',
|
|
messageRecordId: '',
|
|
status: 'accepted_pending',
|
|
phoneCount: 1,
|
|
messages: [
|
|
{
|
|
phoneNumber: '13800000001',
|
|
messageId: 'MSG-stable',
|
|
messageRecordId: '',
|
|
taskId: '',
|
|
status: 'accepted_pending',
|
|
},
|
|
],
|
|
};
|
|
prisma.$queryRaw.mockImplementation((query) => {
|
|
const payloadHash = query.values.find(
|
|
(value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value),
|
|
);
|
|
return Promise.resolve([{ validationError: null, payloadHash, response: storedResponse }]);
|
|
});
|
|
const request = {
|
|
requestId: 'cmpp-inbound:test-retry',
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
sequenceId: 778,
|
|
remoteIp: '127.0.0.1',
|
|
};
|
|
|
|
await service.submitInboundMessage(request);
|
|
await expect(service.submitInboundMessage(request)).resolves.toEqual(storedResponse);
|
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
|
expect(prisma.cmppInboundSubmissionInbox.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
else process.env.CMPP_INBOUND_FAST_PATH_ENABLED = previous;
|
|
}
|
|
});
|
|
|
|
it('persists fast-path Submit before evaluating application or tenant business state', async () => {
|
|
const previous = process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
process.env.CMPP_INBOUND_FAST_PATH_ENABLED = 'true';
|
|
try {
|
|
const { service, prisma } = createService();
|
|
prisma.$queryRaw.mockImplementationOnce((query) => {
|
|
const payloadHash = query.values.find(
|
|
(value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value),
|
|
);
|
|
return Promise.resolve([
|
|
{
|
|
validationError: null,
|
|
payloadHash,
|
|
response: {
|
|
accepted: true,
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
taskId: '',
|
|
messageId: 'MSG-disabled-after-bind',
|
|
messageRecordId: '',
|
|
status: 'accepted_pending',
|
|
phoneCount: 1,
|
|
messages: [],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
await expect(
|
|
service.submitInboundMessage({
|
|
requestId: 'cmpp-inbound:disabled',
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
}),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
accepted: true,
|
|
status: 'accepted_pending',
|
|
}),
|
|
);
|
|
const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' ');
|
|
expect(sql).not.toContain("application.status <> 'active'");
|
|
expect(sql).not.toContain('NOT application."interfaceEnabled"');
|
|
expect(sql).toContain('CMPP source IP is not in application allowlist');
|
|
expect(sql).toContain('CMPP Src_Id must equal the access number assigned to this application');
|
|
expect(prisma.cmppInboundSubmissionInbox.findUnique).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
else process.env.CMPP_INBOUND_FAST_PATH_ENABLED = previous;
|
|
}
|
|
});
|
|
|
|
it('uses a read-only recovery only for a concurrent Inbox insert outside the CTE snapshot', async () => {
|
|
const previous = process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
process.env.CMPP_INBOUND_FAST_PATH_ENABLED = 'true';
|
|
try {
|
|
const { service, prisma } = createService();
|
|
let payloadHash = '';
|
|
prisma.$queryRaw.mockImplementationOnce((query) => {
|
|
payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value));
|
|
return Promise.resolve([{ validationError: null, payloadHash: null, response: null }]);
|
|
});
|
|
prisma.cmppInboundSubmissionInbox.findUnique.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
payloadHash,
|
|
response: { accepted: true, messageId: 'MSG-concurrent', status: 'accepted_pending' },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
service.submitInboundMessage({
|
|
requestId: 'cmpp-inbound:concurrent',
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
}),
|
|
).resolves.toEqual(expect.objectContaining({ messageId: 'MSG-concurrent' }));
|
|
expect(prisma.cmppInboundSubmissionInbox.findUnique).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
|
|
else process.env.CMPP_INBOUND_FAST_PATH_ENABLED = previous;
|
|
}
|
|
});
|
|
|
|
it('persists an idempotent daily quota reservation with a Prisma Date value', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.$queryRaw.mockResolvedValueOnce([{ tenantId: 'tenant-1', dailyLimit: 100000, usedCount: 1 }]);
|
|
|
|
await expect((service as any).tryReserveDailySendQuota('app-1', 1, 'workflow-1:daily-quota')).resolves.toEqual({
|
|
dailyLimit: 100000,
|
|
usedCount: 1,
|
|
reserved: true,
|
|
});
|
|
|
|
expect(prisma.smsApplicationDailyReservation.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
reservationKey: 'workflow-1:daily-quota',
|
|
usageDate: expect.any(Date),
|
|
}),
|
|
});
|
|
expect(prisma.smsApplicationDailyReservation.create.mock.calls[0][0].data.usageDate.toISOString()).toMatch(
|
|
/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/,
|
|
);
|
|
});
|
|
|
|
it('claims Inbox leases against UTC for timestamp-without-time-zone columns', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.$queryRaw.mockResolvedValueOnce([]);
|
|
|
|
await (service as any).submission.inboundEntry.claimInboundWorkflows(5, ['tenant-busy']);
|
|
|
|
const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' ');
|
|
expect(sql).toContain("NOW() AT TIME ZONE 'UTC'");
|
|
expect(sql).toContain('"tenantId" NOT IN');
|
|
expect(prisma.$queryRaw.mock.calls[0][0].values).toContain('tenant-busy');
|
|
expect(sql).toContain('FOR UPDATE SKIP LOCKED');
|
|
expect(sql).toContain('inbox."tenantId"');
|
|
});
|
|
|
|
it('groups Inbox work by tenant and keeps chunks for one tenant serial', async () => {
|
|
const { service } = createService();
|
|
const inboundEntry = (service as any).submission.inboundEntry;
|
|
const items = [
|
|
{ id: 'a1', tenantId: 'tenant-a', applicationId: 'app-1' },
|
|
{ id: 'b1', tenantId: 'tenant-b', applicationId: 'app-2' },
|
|
{ id: 'a2', tenantId: 'tenant-a', applicationId: 'app-1' },
|
|
{ id: 'a3', tenantId: 'tenant-a', applicationId: 'app-1' },
|
|
];
|
|
const grouped = inboundEntry.groupInboundWorkflowsByTenant(items);
|
|
expect([...grouped.keys()]).toEqual(['tenant-a', 'tenant-b']);
|
|
expect(grouped.get('tenant-a').map((item: { id: string }) => item.id)).toEqual(['a1', 'a2', 'a3']);
|
|
|
|
inboundEntry.processClaimedInboundWorkflowBatch = jest.fn().mockResolvedValue(undefined);
|
|
await inboundEntry.processTenantInboundWorkflowBatches(grouped.get('tenant-a'), 2, new Map());
|
|
expect(
|
|
inboundEntry.processClaimedInboundWorkflowBatch.mock.calls.map((call: unknown[]) =>
|
|
(call[0] as Array<{ id: string }>).map((item) => item.id),
|
|
),
|
|
).toEqual([['a1', 'a2'], ['a3']]);
|
|
});
|
|
|
|
it('coalesces a small ready Inbox set before claiming the next tenant batch', async () => {
|
|
const previousWait = process.env.API_INBOUND_WORKFLOW_BATCH_WAIT_MS;
|
|
const previousTarget = process.env.API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE;
|
|
process.env.API_INBOUND_WORKFLOW_BATCH_WAIT_MS = '5';
|
|
process.env.API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE = '32';
|
|
try {
|
|
const { service, prisma } = createService();
|
|
prisma.$queryRaw.mockResolvedValueOnce([{ count: 3n }]);
|
|
const startedAt = Date.now();
|
|
await (service as any).submission.inboundEntry.waitForInboundWorkflowMicroBatch(96, ['tenant-busy']);
|
|
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4);
|
|
const query = prisma.$queryRaw.mock.calls[0][0];
|
|
expect(query.strings.join(' ')).toContain('COUNT(*)::bigint');
|
|
expect(query.values).toContain('tenant-busy');
|
|
} finally {
|
|
if (previousWait == null) delete process.env.API_INBOUND_WORKFLOW_BATCH_WAIT_MS;
|
|
else process.env.API_INBOUND_WORKFLOW_BATCH_WAIT_MS = previousWait;
|
|
if (previousTarget == null) delete process.env.API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE;
|
|
else process.env.API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE = previousTarget;
|
|
}
|
|
});
|
|
|
|
it('uses UTC for Submit Outbox claim, lease, publish, and retry timestamps', async () => {
|
|
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
|
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
|
|
delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
try {
|
|
const { service, prisma } = createService();
|
|
prisma.$queryRaw.mockResolvedValueOnce([{ id: 'outbox-1', submitId: 'SUB-1', payload: { messageId: 'MSG-1' } }]);
|
|
const gatewaySubmit = (service as any).submission.gatewaySubmit;
|
|
await gatewaySubmit.publishSubmitOutboxBatch();
|
|
|
|
const claimSql = prisma.$queryRaw.mock.calls[0][0].strings.join(' ');
|
|
const publishSql = prisma.$executeRaw.mock.calls.at(-1)[0].strings.join(' ');
|
|
expect(claimSql).toContain("NOW() AT TIME ZONE 'UTC'");
|
|
expect(publishSql).toContain("NOW() AT TIME ZONE 'UTC'");
|
|
expect(`${claimSql} ${publishSql}`).not.toContain('CURRENT_TIMESTAMP');
|
|
} finally {
|
|
if (previousShadow == null) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
|
|
if (previousPublish == null) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
|
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
|
}
|
|
});
|
|
|
|
it('enqueues a freshly persisted inbound message without querying the task and message again', async () => {
|
|
const { service, prisma } = createService();
|
|
const add = jest.fn().mockResolvedValue(undefined);
|
|
service['getSendQueue'] = jest.fn().mockReturnValue({ add });
|
|
|
|
await expect(
|
|
service.enqueueBatchTask('task-1', {
|
|
messageRecordId: 'record-1',
|
|
queuePriority: 'priority',
|
|
}),
|
|
).resolves.toEqual({ taskId: 'task-1', enqueued: 1 });
|
|
|
|
expect(prisma.smsBatchTask.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled();
|
|
expect(add).toHaveBeenCalledWith(
|
|
'send-message',
|
|
{ messageRecordId: 'record-1' },
|
|
{
|
|
jobId: 'record-1',
|
|
attempts: 3,
|
|
priority: 1,
|
|
},
|
|
);
|
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } });
|
|
});
|
|
|
|
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.toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: expect.objectContaining({ carrier: expect.any(String) }) }),
|
|
);
|
|
});
|
|
|
|
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', 'timeout'] } },
|
|
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
|
|
});
|
|
expect(billing.settleFrozenCharge).toHaveBeenCalledWith(
|
|
expect.objectContaining({ amountCents: 3, taskId: 'task-1', messageId: '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('treats a redelivered Outbox aggregate event as idempotent', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
const baseSubmit = {
|
|
id: 'submit-1',
|
|
messageRecordId: 'record-1',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-1',
|
|
submitStatus: 'accepted',
|
|
};
|
|
prisma.smsSubmitRecord.findUnique
|
|
.mockResolvedValueOnce({ ...baseSubmit, resultEventId: null })
|
|
.mockResolvedValueOnce({ ...baseSubmit, resultEventId: 'submit:SUB-1:aggregate' });
|
|
const event = {
|
|
eventId: 'submit:SUB-1:aggregate',
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-1',
|
|
sequenceId: 7,
|
|
gatewayMessageId: 'GW-1',
|
|
submitStatus: 'accepted' as const,
|
|
submittedAt: '2026-07-01T10:00:00.000Z',
|
|
};
|
|
|
|
await service.handleSubmitResult(event);
|
|
await service.handleSubmitResult(event);
|
|
|
|
expect(billing.settleFrozenCharge).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: 'submit-1',
|
|
OR: [{ resultEventId: null }, { resultEventId: 'submit:SUB-1:aggregate' }],
|
|
},
|
|
data: {
|
|
resultEventId: 'submit:SUB-1:aggregate',
|
|
resultProcessedAt: new Date('2026-07-01T10:00:00.000Z'),
|
|
},
|
|
});
|
|
});
|
|
|
|
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(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('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).not.toHaveBeenCalled();
|
|
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
|
|
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
group: {
|
|
id: 'group-1',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: false,
|
|
retryTimeLimitHours: 72,
|
|
retryTimeLimitMinutes: 4320,
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
await service.handleSubmitResult({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-1',
|
|
gatewayMessageId: 'GW-1',
|
|
submitStatus: 'rejected',
|
|
});
|
|
expect(billing.release).toHaveBeenCalledWith(
|
|
expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }),
|
|
);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-1',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'UNDELIV',
|
|
});
|
|
expect(billing.refund).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
idempotencyKey: 'sms-refund:MSG-1',
|
|
remark: '最终失败退款',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('stops failed receipt retry after the configured minute limit', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
|
|
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-1',
|
|
status: 'submitted',
|
|
amountCents: 3,
|
|
billingUnits: 1,
|
|
unitPrice: 3,
|
|
queuedAt: new Date(Date.now() - 90 * 60_000),
|
|
});
|
|
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
group: {
|
|
id: 'group-1',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: true,
|
|
retryTimeLimitHours: 2,
|
|
retryTimeLimitMinutes: 75,
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-1',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'UNDELIV',
|
|
});
|
|
|
|
expect(prisma.smsSubmitRecord.create).not.toHaveBeenCalled();
|
|
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
|
where: { id: 'record-1' },
|
|
data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered' }),
|
|
});
|
|
});
|
|
|
|
it('does not let stale failed receipts overwrite a later delivered message', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
templateId: 'tpl-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
channelId: 'channel-new',
|
|
gatewayMessageId: 'GW-NEW',
|
|
status: 'delivered',
|
|
amountCents: 3,
|
|
billingUnits: 1,
|
|
unitPrice: 3,
|
|
});
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-old',
|
|
gatewayMessageId: 'GW-OLD',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'UNDELIV',
|
|
});
|
|
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
channelId: 'channel-old',
|
|
gatewayMessageId: 'GW-OLD',
|
|
receiptStatus: 'undelivered',
|
|
}),
|
|
});
|
|
expect(billing.refund).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith({
|
|
where: { id: 'record-1' },
|
|
data: expect.objectContaining({ status: 'failed' }),
|
|
});
|
|
});
|
|
|
|
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
|
prisma.smsSubmitRecord.findMany
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'submit-timeout-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: null,
|
|
submitStatus: 'timeout',
|
|
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
|
|
messageRecord: {
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: null,
|
|
status: 'timeout',
|
|
},
|
|
},
|
|
]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'receipt-123456789',
|
|
channelId: 'channel-1',
|
|
sequenceId: 7,
|
|
gatewayMessageId: 'GW-RECOVERED-1',
|
|
phoneNumber: '13800000001',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: '2026-07-01T10:01:00.000Z',
|
|
});
|
|
|
|
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: 'submit-timeout-1',
|
|
gatewayMessageId: null,
|
|
},
|
|
data: {
|
|
gatewayMessageId: 'GW-RECOVERED-1',
|
|
sequenceId: 7,
|
|
},
|
|
});
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
messageRecordId: 'record-1',
|
|
messageId: 'MSG-1',
|
|
gatewayMessageId: 'GW-RECOVERED-1',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('matches identical upstream Msg_Id values by channel and destination instead of another channel record', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
|
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([
|
|
{
|
|
id: 'submit-channel-b',
|
|
channelId: 'channel-b',
|
|
gatewayMessageId: 'SHARED-UPSTREAM-ID',
|
|
messageRecord: {
|
|
id: 'record-channel-b',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-B',
|
|
phoneNumber: '15601992925',
|
|
channelId: 'channel-b',
|
|
gatewayMessageId: 'SHARED-UPSTREAM-ID',
|
|
status: 'submitted',
|
|
},
|
|
},
|
|
]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'receipt-SHARED-UPSTREAM-ID',
|
|
channelId: 'channel-b',
|
|
gatewayMessageId: 'SHARED-UPSTREAM-ID',
|
|
phoneNumber: '15601992925',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: '2026-07-01T10:01:00.000Z',
|
|
});
|
|
|
|
expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
channelId: 'channel-b',
|
|
gatewayMessageId: 'SHARED-UPSTREAM-ID',
|
|
messageRecord: { phoneNumber: '15601992925' },
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
|
where: { id: 'record-channel-b' },
|
|
data: expect.objectContaining({
|
|
status: 'delivered',
|
|
receiptStatus: 'delivered',
|
|
channelId: 'channel-b',
|
|
gatewayMessageId: 'SHARED-UPSTREAM-ID',
|
|
receiptRawStatus: 'DELIVRD',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('matches a receipt from another connection only when it is the unique channel of the same supplier', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
|
prisma.smsChannel.findUnique.mockResolvedValue({
|
|
id: 'channel-copy',
|
|
account: 'C59748',
|
|
gatewayHost: 'supplier.example.com',
|
|
gatewayPort: 7890,
|
|
protocol: 'CMPP',
|
|
cmppVersion: '2.0',
|
|
});
|
|
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]);
|
|
prisma.smsMessageSegmentAudit.findMany
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'segment-2',
|
|
submitId: 'SUB-LONG-1',
|
|
submitRecordId: 'submit-original',
|
|
channelId: 'channel-original',
|
|
gatewayMessageId: '736070230367350788',
|
|
submitRecord: { id: 'submit-original', submitId: 'SUB-LONG-1' },
|
|
channel: {
|
|
id: 'channel-original',
|
|
account: 'C59748',
|
|
gatewayHost: 'supplier.example.com',
|
|
gatewayPort: 7890,
|
|
protocol: 'CMPP',
|
|
cmppVersion: '2.0',
|
|
},
|
|
messageRecord: {
|
|
id: 'record-long',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-LONG-1',
|
|
submitId: 'SUB-LONG-1',
|
|
phoneNumber: '13127620092',
|
|
channelId: 'channel-original',
|
|
gatewayMessageId: '736070227905294338',
|
|
status: 'submitted',
|
|
billingUnits: 2,
|
|
},
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'receipt-736070230367350788',
|
|
channelId: 'channel-copy',
|
|
gatewayMessageId: '736070230367350788',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
});
|
|
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
channelId: 'channel-original',
|
|
messageRecordId: 'record-long',
|
|
messageId: 'MSG-LONG-1',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('does not match the same Msg_Id across channels belonging to different suppliers', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
|
prisma.smsChannel.findUnique.mockResolvedValue({
|
|
id: 'channel-other',
|
|
account: 'OTHER',
|
|
gatewayHost: 'other.example.com',
|
|
gatewayPort: 7890,
|
|
protocol: 'CMPP',
|
|
cmppVersion: '2.0',
|
|
});
|
|
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
prisma.smsMessageSegmentAudit.findMany.mockResolvedValueOnce([
|
|
{
|
|
id: 'segment-original',
|
|
submitRecordId: 'submit-original',
|
|
submitId: 'SUB-ORIGINAL',
|
|
channelId: 'channel-original',
|
|
gatewayMessageId: 'SHARED-ID',
|
|
submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' },
|
|
channel: {
|
|
id: 'channel-original',
|
|
account: 'C59748',
|
|
gatewayHost: 'supplier.example.com',
|
|
gatewayPort: 7890,
|
|
protocol: 'CMPP',
|
|
cmppVersion: '2.0',
|
|
},
|
|
messageRecord: {
|
|
id: 'record-original',
|
|
messageId: 'MSG-ORIGINAL',
|
|
phoneNumber: '13127620092',
|
|
},
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
service.handleReceipt({
|
|
messageId: 'receipt-SHARED-ID',
|
|
channelId: 'channel-other',
|
|
gatewayMessageId: 'SHARED-ID',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
}),
|
|
).rejects.toThrow('SMS message record not found');
|
|
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('waits for every long-message segment before marking the main message delivered', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
|
id: 'record-long',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-LONG-1',
|
|
submitId: 'SUB-LONG-1',
|
|
phoneNumber: '13127620092',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
status: 'submitted',
|
|
billingUnits: 2,
|
|
cmppSubmitSequenceId: '501',
|
|
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-1',
|
|
cmppRegisteredDelivery: true,
|
|
});
|
|
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
|
|
id: 'long-group-receipt-1',
|
|
messageId: 'MSG-LONG-GROUP-1',
|
|
segmentTotal: 2,
|
|
segments: [
|
|
{ segmentIndex: 1, sequenceId: '501', registeredDelivery: true },
|
|
{ segmentIndex: 2, sequenceId: '502', registeredDelivery: true },
|
|
],
|
|
});
|
|
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
|
id: 'submit-long',
|
|
submitId: 'SUB-LONG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
});
|
|
prisma.smsMessageSegmentAudit.findMany
|
|
.mockResolvedValueOnce([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-LONG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'delivered' }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
|
|
|
prisma.smsReceiptRecord.findUnique.mockResolvedValue(null);
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-LONG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'record-long' },
|
|
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, {
|
|
data: expect.objectContaining({
|
|
dedupeKey: 'receipt:record-long:segment:1',
|
|
payload: expect.objectContaining({ submitSequenceId: 501, clientSegmentIndex: 1, clientSegmentTotal: 2 }),
|
|
}),
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(2, {
|
|
data: expect.objectContaining({
|
|
dedupeKey: 'receipt:record-long:segment:2',
|
|
payload: expect.objectContaining({ submitSequenceId: 502, clientSegmentIndex: 2, clientSegmentTotal: 2 }),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('treats one delivered receipt as the whole long-message success only for a message-level receipt channel', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsChannel.findUnique.mockResolvedValue({
|
|
id: 'channel-1',
|
|
config: { longMessageReceiptMode: 'message_level' },
|
|
});
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
|
id: 'record-message-level',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-MESSAGE-LEVEL',
|
|
submitId: 'SUB-MESSAGE-LEVEL',
|
|
phoneNumber: '13127620092',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
|
status: 'submitted',
|
|
billingUnits: 2,
|
|
});
|
|
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
|
id: 'submit-message-level',
|
|
submitId: 'SUB-MESSAGE-LEVEL',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
|
});
|
|
prisma.smsMessageSegmentAudit.findMany
|
|
.mockResolvedValueOnce([
|
|
{ id: 'segment-1', receiptStatus: 'delivered' },
|
|
{ id: 'segment-2', receiptStatus: null },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
{
|
|
segmentIndex: 2,
|
|
segmentTotal: 2,
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
compensationType: 'supplier_message_level_receipt',
|
|
deliveredAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-MESSAGE-LEVEL',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
});
|
|
|
|
expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
messageRecordId: 'record-message-level',
|
|
submitRecordId: 'submit-message-level',
|
|
receiptStatus: null,
|
|
}),
|
|
data: expect.objectContaining({
|
|
receiptStatus: 'delivered',
|
|
compensationType: 'supplier_message_level_receipt',
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'record-message-level' },
|
|
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records a receipt anomaly when a message-level success is followed by a failure for the same attempt', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsChannel.findUnique.mockResolvedValue({
|
|
id: 'channel-1',
|
|
config: { longMessageReceiptMode: 'message_level' },
|
|
});
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
|
id: 'record-conflict',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-CONFLICT',
|
|
submitId: 'SUB-CONFLICT',
|
|
phoneNumber: '13127620092',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-CONFLICT-1',
|
|
status: 'delivered',
|
|
billingUnits: 2,
|
|
});
|
|
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
|
id: 'segment-conflict-2',
|
|
messageRecordId: 'record-conflict',
|
|
submitRecordId: 'submit-conflict',
|
|
submitId: 'SUB-CONFLICT',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-CONFLICT-2',
|
|
segmentIndex: 2,
|
|
segmentTotal: 2,
|
|
});
|
|
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
|
|
]);
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-CONFLICT',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-CONFLICT-2',
|
|
phoneNumber: '13127620092',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'UNDELIV',
|
|
errorCode: 'SP_CONFLICT',
|
|
});
|
|
|
|
expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' },
|
|
create: expect.objectContaining({
|
|
anomalyType: 'aggregate_success_then_failure',
|
|
previousStatus: 'delivered',
|
|
incomingStatus: 'undelivered',
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'failed' }),
|
|
}),
|
|
);
|
|
expect(billing.refund).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => {
|
|
const { service, prisma } = createService();
|
|
let claimedDelivery: Record<string, unknown> | null = null;
|
|
prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => {
|
|
if (claimedDelivery) {
|
|
throw new Prisma.PrismaClientKnownRequestError('duplicate downstream receipt', {
|
|
code: 'P2002',
|
|
clientVersion: '7.9.0',
|
|
meta: { target: ['dedupeKey'] },
|
|
});
|
|
}
|
|
claimedDelivery = {
|
|
id: 'delivery-once',
|
|
...data,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
return claimedDelivery;
|
|
});
|
|
prisma.cmppDownstreamDelivery.findUnique.mockImplementation(({ where }) =>
|
|
Promise.resolve(where.dedupeKey || where.id === 'delivery-once' ? claimedDelivery : null),
|
|
);
|
|
const payload = {
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-long-race',
|
|
messageId: 'MSG-LONG-RACE',
|
|
deliveryType: 'receipt' as const,
|
|
payload: {
|
|
messageId: 'MSG-LONG-RACE',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'FLNIGLK',
|
|
},
|
|
};
|
|
|
|
const results = await Promise.all([
|
|
(service as any).queueAndTryDownstreamDelivery(payload),
|
|
(service as any).queueAndTryDownstreamDelivery(payload),
|
|
(service as any).queueAndTryDownstreamDelivery(payload),
|
|
]);
|
|
|
|
expect(results.map((result) => result.id)).toEqual(['delivery-once', 'delivery-once', 'delivery-once']);
|
|
expect(service['postGatewayControl']).toHaveBeenCalledTimes(1);
|
|
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
|
'/downstream/receipt',
|
|
expect.objectContaining({ deliveryId: 'delivery-once', claimId: expect.stringMatching(/^api-direct:/) }),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'delivery-once', status: 'pending' },
|
|
data: expect.objectContaining({ status: 'dispatching', connectionId: expect.stringMatching(/^api-direct:/) }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('marks a long message failed when a non-primary segment returns an explicit failure', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
|
id: 'record-long',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-LONG-FAIL',
|
|
submitId: 'SUB-LONG-FAIL',
|
|
phoneNumber: '18821203795',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-1',
|
|
status: 'submitted',
|
|
billingUnits: 2,
|
|
amountCents: 6,
|
|
unitPrice: 3,
|
|
cmppSubmitSequenceId: '601',
|
|
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-FAIL',
|
|
cmppRegisteredDelivery: true,
|
|
});
|
|
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
|
|
id: 'long-group-receipt-fail',
|
|
messageId: 'MSG-LONG-GROUP-FAIL',
|
|
segmentTotal: 2,
|
|
segments: [
|
|
{ segmentIndex: 1, sequenceId: '601', registeredDelivery: true },
|
|
{ segmentIndex: 2, sequenceId: '602', registeredDelivery: true },
|
|
],
|
|
});
|
|
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
|
id: 'segment-2',
|
|
messageRecordId: 'record-long',
|
|
submitRecordId: 'submit-long',
|
|
submitId: 'SUB-LONG-FAIL',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
segmentIndex: 2,
|
|
segmentTotal: 2,
|
|
});
|
|
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
|
|
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
|
|
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() },
|
|
]);
|
|
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) =>
|
|
Promise.resolve(
|
|
where.id === 'submit-long'
|
|
? {
|
|
id: 'submit-long',
|
|
messageRecordId: 'record-long',
|
|
channelId: 'channel-1',
|
|
submitId: 'SUB-LONG-FAIL',
|
|
submitStatus: 'accepted',
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' });
|
|
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
|
id: 'route-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
groupId: 'group-1',
|
|
carrier: 'mobile',
|
|
group: {
|
|
id: 'group-1',
|
|
carrier: 'mobile',
|
|
status: 'active',
|
|
retryEnabled: false,
|
|
retryTimeLimitHours: 72,
|
|
retryTimeLimitMinutes: 4320,
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
await service.handleReceipt({
|
|
messageId: 'MSG-LONG-FAIL',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-SEG-2',
|
|
phoneNumber: '18821203795',
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'YL:1014',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
|
where: { id: 'record-long' },
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
receiptStatus: 'undelivered',
|
|
receiptRawStatus: 'YL:1014',
|
|
}),
|
|
});
|
|
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending' }),
|
|
});
|
|
});
|
|
|
|
it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsReceiptRecord.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
|
id: 'receipt-existing',
|
|
messageRecordId: 'record-1',
|
|
messageRecord: { id: 'record-1', messageId: 'MSG-1', status: 'delivered' },
|
|
});
|
|
|
|
const receipt = {
|
|
messageId: 'MSG-1',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-1',
|
|
phoneNumber: '13800000001',
|
|
receiptStatus: 'delivered' as const,
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: '2026-07-01T10:01:00.000Z',
|
|
};
|
|
await service.handleReceipt(receipt);
|
|
await service.handleReceipt(receipt);
|
|
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
|
prisma.smsSubmitRecord.findMany
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'submit-timeout-1',
|
|
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
|
|
},
|
|
{
|
|
id: 'submit-timeout-2',
|
|
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
service.handleReceipt({
|
|
messageId: 'receipt-ambiguous',
|
|
channelId: 'channel-1',
|
|
gatewayMessageId: 'GW-AMBIGUOUS',
|
|
phoneNumber: '13800000001',
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
}),
|
|
).rejects.toThrow('SMS message record not found');
|
|
|
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks submit when signature is not approved on the selected channel', async () => {
|
|
const { service, prisma } = createService();
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
const route = await prisma.channelRouteRule.findFirst();
|
|
prisma.channelRouteRule.findFirst.mockResolvedValue({ ...route, group: { ...route.group, items: [] } });
|
|
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', phoneTotal: 1 },
|
|
template: { signature: { id: 'sig-1', name: '签名' } },
|
|
});
|
|
|
|
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
|
expect.objectContaining({ submitted: false, status: 'failed', reason: '无已报备通过且在线的可用通道' }),
|
|
);
|
|
expect(service['publishGatewaySubmitCommand']).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();
|
|
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
|
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(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
|
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',
|
|
gatewayMessageId: '8412634832294102675',
|
|
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',
|
|
gatewayMessageId: '8412634832294102675',
|
|
content: 'TD',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('records ambiguous uplink match candidates for shared access numbers', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]);
|
|
prisma.smsApplication.findMany.mockResolvedValue([
|
|
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
|
|
{ id: 'app-2', tenantId: 'tenant-2', name: '应用B' },
|
|
]);
|
|
|
|
await service.handleUplink({
|
|
channelId: 'channel-1',
|
|
sequenceId: 8,
|
|
phoneNumber: '13800000001',
|
|
destId: '10690000',
|
|
content: '回复TD',
|
|
receivedAt: '2026-07-01T10:02:00.000Z',
|
|
});
|
|
|
|
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: undefined,
|
|
applicationId: undefined,
|
|
matchStatus: 'ambiguous',
|
|
matchReason: '接入号匹配多个应用',
|
|
}),
|
|
});
|
|
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
|
|
data: [
|
|
expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
matchSource: 'access_number',
|
|
confidence: 70,
|
|
}),
|
|
expect.objectContaining({
|
|
tenantId: 'tenant-2',
|
|
applicationId: 'app-2',
|
|
matchSource: 'access_number',
|
|
confidence: 70,
|
|
}),
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
});
|
|
|
|
it('claims an ambiguous uplink candidate and queues downstream uplink delivery', async () => {
|
|
const { service, prisma } = createService();
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
|
|
|
await service.claimUplinkMatchCandidate('uplink-1', 'candidate-1', 'admin-1');
|
|
|
|
expect(prisma.smsUplinkMessage.update).toHaveBeenCalledWith({
|
|
where: { id: 'uplink-1' },
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
messageId: 'MSG-1',
|
|
matchStatus: 'matched',
|
|
}),
|
|
});
|
|
expect(prisma.smsUplinkMatchCandidate.updateMany).toHaveBeenCalledWith({
|
|
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
|
|
data: { status: 'rejected' },
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'uplink',
|
|
status: 'pending',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('records gateway submit exceptions and safely allows manual requeue', async () => {
|
|
const { service, prisma } = createService();
|
|
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0');
|
|
|
|
await service.recordGatewaySubmitDeadLetter({
|
|
streamMessageId: '1710000000000-0',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
channelId: 'channel-1',
|
|
traceId: 'trace-1',
|
|
messageId: 'MSG-1',
|
|
submitId: 'SUB-1',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
failureMessage: 'network down',
|
|
attempts: 3,
|
|
maxAttempts: 3,
|
|
commandPayload: { messageType: 'SubmitCommand', submitId: 'SUB-1' },
|
|
rawPayload: '{"messageType":"SubmitCommand"}',
|
|
deadLetteredAt: '2026-07-08T12:00:00.000Z',
|
|
});
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith({
|
|
where: { streamMessageId: '1710000000000-0' },
|
|
update: expect.objectContaining({
|
|
submitId: 'SUB-1',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
attempts: 3,
|
|
}),
|
|
create: expect.objectContaining({
|
|
streamMessageId: '1710000000000-0',
|
|
failureMessage: 'network down',
|
|
}),
|
|
});
|
|
|
|
await service.requeueGatewaySubmitDeadLetter('dead-1', {
|
|
confirmedNotSubmitted: true,
|
|
reason: '确认通道连接失败且运营商未收到该短信',
|
|
operatorId: 'user-1',
|
|
});
|
|
|
|
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
|
expect.objectContaining({ submitId: 'SUB-1' }),
|
|
'gateway:submit:requeue:dead-1:1',
|
|
);
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'dead-1', status: 'pending' },
|
|
data: { status: 'requeueing' },
|
|
});
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'dead-1', status: 'requeueing' },
|
|
data: expect.objectContaining({
|
|
status: 'requeued',
|
|
manualRetryCount: { increment: 1 },
|
|
lastRetryStreamId: '1710000001000-0',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.submit_dead_letter_requeue',
|
|
resource: 'gateway_submit_dead_letter',
|
|
resourceId: 'dead-1',
|
|
userId: 'user-1',
|
|
detail: expect.objectContaining({
|
|
reason: '确认通道连接失败且运营商未收到该短信',
|
|
confirmedNotSubmitted: true,
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('blocks submit exception requeue when the upstream result may already be accepted', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.smsMessageRecord.findUnique.mockResolvedValueOnce({
|
|
id: 'record-1',
|
|
messageId: 'MSG-1',
|
|
status: 'submitted',
|
|
submitStatus: 'accepted',
|
|
receiptStatus: null,
|
|
});
|
|
|
|
await expect(
|
|
service.requeueGatewaySubmitDeadLetter('dead-1', {
|
|
confirmedNotSubmitted: true,
|
|
reason: '尝试重新发送这条短信',
|
|
}),
|
|
).rejects.toThrow('为避免重复发送,禁止重新入队');
|
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('marks a pending gateway submit exception as resolved without requeueing it', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.gatewaySubmitDeadLetter.findUnique
|
|
.mockResolvedValueOnce({
|
|
id: 'dead-1',
|
|
tenantId: 'tenant-1',
|
|
status: 'pending',
|
|
messageId: 'MSG-1',
|
|
submitId: 'SUB-1',
|
|
})
|
|
.mockResolvedValueOnce({ id: 'dead-1', status: 'resolved', resolvedStatus: 'manually_resolved' });
|
|
prisma.gatewaySubmitDeadLetter.updateMany.mockResolvedValueOnce({ count: 1 });
|
|
|
|
await expect(service.resolveGatewaySubmitDeadLetter('dead-1', 'user-1')).resolves.toEqual(
|
|
expect.objectContaining({ status: 'resolved', resolvedStatus: 'manually_resolved' }),
|
|
);
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'dead-1', status: 'pending' },
|
|
data: expect.objectContaining({
|
|
status: 'resolved',
|
|
resolvedAt: expect.any(Date),
|
|
resolvedStatus: 'manually_resolved',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
userId: 'user-1',
|
|
action: 'gateway.submit_dead_letter_resolved',
|
|
resourceId: 'dead-1',
|
|
}),
|
|
});
|
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.recordGatewaySubmitDeadLetter({
|
|
streamMessageId: '1710000000000-0',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
failureMessage: 'repeated report',
|
|
attempts: 3,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
update: expect.not.objectContaining({
|
|
status: expect.anything(),
|
|
resolvedAt: expect.anything(),
|
|
resolvedStatus: expect.anything(),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('allows a disabling application to reconnect for receipt draining and audits later Submit as REJECTD', 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' },
|
|
});
|
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
cmppAccount: '100001',
|
|
status: 'deleted',
|
|
interfaceEnabled: false,
|
|
downstreamReceiptRetryEnabled: true,
|
|
downstreamUplinkRetryEnabled: true,
|
|
httpConfig: { enabled: false },
|
|
});
|
|
|
|
await expect(
|
|
service.authenticateInboundApplication({
|
|
account: '100001',
|
|
password: 'secret-hash',
|
|
remoteIp: '127.0.0.1',
|
|
version: 'cmpp30',
|
|
requestedVersion: 48,
|
|
}),
|
|
).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
|
|
await expect(
|
|
service.submitInboundMessage({
|
|
account: '100001',
|
|
phoneNumber: '13800000001',
|
|
content: 'hello',
|
|
sequenceId: 702,
|
|
remoteIp: '127.0.0.1',
|
|
}),
|
|
).resolves.toEqual(expect.objectContaining({ accepted: true, status: 'accepted' }));
|
|
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }),
|
|
}),
|
|
);
|
|
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
|
'/downstream/receipt',
|
|
expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }),
|
|
);
|
|
});
|
|
|
|
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([]);
|
|
|
|
prisma.$queryRaw.mockResolvedValueOnce([]);
|
|
await expect(
|
|
service.listPendingDownstreamDeliveries({ account: '100001', limit: 100, claimId: 'gateway-a:100001:1' }),
|
|
).resolves.toEqual([]);
|
|
const claimSql = prisma.$queryRaw.mock.calls[0][0].strings.join(' ');
|
|
expect(claimSql).toContain('FOR UPDATE SKIP LOCKED');
|
|
expect(claimSql).toContain("status = 'dispatching'");
|
|
});
|
|
|
|
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', 'timeout'] } },
|
|
data: expect.objectContaining({ status: 'submitted' }),
|
|
}),
|
|
);
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(2, {
|
|
where: { id: 'record-1', gatewayMessageId: null },
|
|
data: expect.objectContaining({ gatewayMessageId: 'GW-1' }),
|
|
});
|
|
});
|
|
|
|
it('recovers a stale submit requeue with the same Redis idempotency key', async () => {
|
|
const { service, prisma } = createService();
|
|
const stale = {
|
|
...(await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } })),
|
|
status: 'requeueing',
|
|
updatedAt: new Date('2026-07-21T07:00:00.000Z'),
|
|
};
|
|
prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]);
|
|
const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0');
|
|
|
|
await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({
|
|
recovered: 1,
|
|
failed: 0,
|
|
});
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, {
|
|
where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt },
|
|
data: { status: 'requeue_recovering' },
|
|
});
|
|
expect(publish).toHaveBeenCalledWith(stale.commandPayload, 'gateway:submit:requeue:dead-1:1');
|
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, {
|
|
where: { id: 'dead-1', status: 'requeue_recovering' },
|
|
data: expect.objectContaining({
|
|
status: 'requeued',
|
|
manualRetryCount: { increment: 1 },
|
|
lastRetryStreamId: '1710000001000-0',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => {
|
|
const { service, prisma } = createService();
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' });
|
|
prisma.cmppDownstreamDelivery.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 });
|
|
|
|
const results = await Promise.allSettled([
|
|
service.requeueDownstreamDelivery('delivery-1'),
|
|
service.requeueDownstreamDelivery('delivery-1'),
|
|
]);
|
|
|
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
|
expect(service['postGatewayControl']).toHaveBeenCalledTimes(1);
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: 'delivery-1',
|
|
status: 'failed',
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
},
|
|
data: expect.objectContaining({
|
|
status: 'manual_requeueing',
|
|
manualRetryCount: { increment: 1 },
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('recovers a stale downstream manual-requeue claim into the Gateway pending path', async () => {
|
|
const { service, prisma } = createService();
|
|
const updatedAt = new Date('2026-07-21T07:00:00.000Z');
|
|
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]);
|
|
|
|
await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({
|
|
recovered: 1,
|
|
});
|
|
|
|
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({
|
|
where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } },
|
|
select: { id: true, updatedAt: true },
|
|
orderBy: { updatedAt: 'asc' },
|
|
take: 500,
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'delivery-1', status: 'manual_requeueing', updatedAt },
|
|
data: {
|
|
status: 'pending',
|
|
nextRetryAt: null,
|
|
lastError: '人工重投进程中断,已恢复为待投递',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('records gateway downstream recovery statuses', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await expect(
|
|
service.recordGatewayDownstreamRecoveryStatus({
|
|
account: '100001',
|
|
gatewayInstanceId: 'gateway-a',
|
|
state: 'waiting_connection',
|
|
lastAttemptAt: '2026-07-08T12:00:00.000Z',
|
|
nextRetryAt: '2026-07-08T12:10:00.000Z',
|
|
attemptCount: 2,
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: '2026-07-08T12:00:30.000Z',
|
|
lastError: 'downstream client is not connected',
|
|
}),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
id: 'recover-1',
|
|
account: '100001',
|
|
state: 'waiting_connection',
|
|
}),
|
|
);
|
|
|
|
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { account: '100001' },
|
|
update: expect.objectContaining({
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
|
failureCategory: 'client_disconnected',
|
|
}),
|
|
create: expect.objectContaining({
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
|
failureCategory: 'client_disconnected',
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_recovery_status_changed',
|
|
resource: 'gateway_downstream_recovery_status',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not append recovery audit logs when only periodic timestamps change', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({
|
|
state: 'waiting_connection',
|
|
gatewayInstanceId: 'gateway-a',
|
|
lockOwner: 'gateway-a',
|
|
failureCategory: 'client_disconnected',
|
|
lastError: 'downstream client is not connected',
|
|
lastSkipReason: null,
|
|
});
|
|
|
|
await service.recordGatewayDownstreamRecoveryStatus({
|
|
account: '100001',
|
|
gatewayInstanceId: 'gateway-a',
|
|
state: 'waiting_connection',
|
|
lastAttemptAt: '2026-07-08T12:01:00.000Z',
|
|
nextRetryAt: '2026-07-08T12:11:00.000Z',
|
|
attemptCount: 3,
|
|
lockOwner: 'gateway-a',
|
|
lockExpiresAt: '2026-07-08T12:01:30.000Z',
|
|
lastError: 'downstream client is not connected',
|
|
});
|
|
|
|
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled();
|
|
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('marks downstream delivery as failed after reaching retry limit', async () => {
|
|
const { service, prisma } = createService();
|
|
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
|
process.env.CMPP_DOWNSTREAM_MAX_RETRIES = '2';
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 1,
|
|
lastError: null,
|
|
});
|
|
|
|
try {
|
|
await service.markDownstreamDeliveryFailed('delivery-1', 'client offline');
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
|
} else {
|
|
process.env.CMPP_DOWNSTREAM_MAX_RETRIES = previous;
|
|
}
|
|
}
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({
|
|
where: { id: 'delivery-1' },
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
retryCount: 2,
|
|
nextRetryAt: null,
|
|
lastError: 'client offline',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_delivery_failed',
|
|
resource: 'cmpp_downstream_delivery',
|
|
resourceId: 'delivery-1',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('immediately terminates an unrecoverable downstream delivery', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 0,
|
|
retryEnabled: true,
|
|
});
|
|
|
|
await service.markDownstreamDeliveryFailed(
|
|
'delivery-1',
|
|
'历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id',
|
|
'unrecoverable',
|
|
);
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
retryCount: 1,
|
|
nextRetryAt: null,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not let the pending timeout scan overwrite a delivery that is already awaiting acknowledgement', async () => {
|
|
const { service, prisma } = createService();
|
|
const awaitingAck = {
|
|
id: 'delivery-1',
|
|
status: 'awaiting_ack',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 0,
|
|
};
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue(awaitingAck);
|
|
|
|
await expect(
|
|
service.markDownstreamDeliveryFailed('delivery-1', '下游投递排队超过 72 小时,系统自动终止重试', 'queue_timeout'),
|
|
).resolves.toEqual(awaitingAck);
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('only marks downstream delivery delivered after a successful CMPP_DELIVER_RESP', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.markDownstreamDeliverySent({
|
|
id: 'delivery-1',
|
|
connectionId: 'conn-1',
|
|
sequenceId: '37',
|
|
messageId: '9016479179509871733',
|
|
sentAt: '2026-07-14T03:40:18.030Z',
|
|
ackDeadlineAt: '2026-07-14T03:40:48.030Z',
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'delivery-1', status: { not: 'delivered' } },
|
|
data: expect.objectContaining({ status: 'awaiting_ack', ackSequenceId: '37', connectionId: 'conn-1' }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
create: expect.objectContaining({
|
|
deliveryId: 'delivery-1',
|
|
attemptNo: 1,
|
|
connectionId: 'conn-1',
|
|
sequenceId: '37',
|
|
status: 'awaiting_ack',
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await service.acknowledgeDownstreamDelivery({
|
|
id: 'delivery-1',
|
|
connectionId: 'conn-1',
|
|
sequenceId: '37',
|
|
messageId: '9016479179509871733',
|
|
result: 0,
|
|
acknowledgedAt: '2026-07-14T03:40:18.060Z',
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: 'delivered',
|
|
ackResult: 0,
|
|
deliveredAt: new Date('2026-07-14T03:40:18.060Z'),
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
update: expect.objectContaining({
|
|
status: 'acknowledged',
|
|
acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'),
|
|
ackResult: 0,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not treat Result=0 with Msg_Id=0 as a business acknowledgement', async () => {
|
|
const { service, prisma } = createService();
|
|
|
|
await service.acknowledgeDownstreamDelivery({
|
|
id: 'delivery-1',
|
|
connectionId: 'conn-1',
|
|
sequenceId: '91',
|
|
messageId: '0',
|
|
result: 0,
|
|
acknowledgedAt: '2026-07-14T07:07:49.336Z',
|
|
});
|
|
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'delivered' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'awaiting_ack',
|
|
retryEnabled: false,
|
|
retryCount: 0,
|
|
});
|
|
|
|
await service.markDownstreamDeliveryFailed('delivery-1', 'CMPP_DELIVER_RESP timeout', 'ack_timeout');
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'unconfirmed', retryCount: 1, nextRetryAt: null }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('uses exponential backoff for downstream delivery retries before final failure', async () => {
|
|
const { service, prisma } = createService();
|
|
const previousBase = process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
|
const previousMax = process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
|
const now = Date.UTC(2026, 6, 8, 12, 0, 0);
|
|
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now);
|
|
process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = '60000';
|
|
process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = '600000';
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
retryCount: 2,
|
|
lastError: null,
|
|
});
|
|
|
|
try {
|
|
await service.markDownstreamDeliveryFailed('delivery-1', 'temporary network jitter');
|
|
} finally {
|
|
dateNowSpy.mockRestore();
|
|
if (previousBase === undefined) {
|
|
delete process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
|
} else {
|
|
process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = previousBase;
|
|
}
|
|
if (previousMax === undefined) {
|
|
delete process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
|
} else {
|
|
process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = previousMax;
|
|
}
|
|
}
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({
|
|
where: { id: 'delivery-1' },
|
|
data: expect.objectContaining({
|
|
status: 'pending',
|
|
retryCount: 3,
|
|
nextRetryAt: new Date(now + 240_000),
|
|
lastError: 'temporary network jitter',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('requeues downstream delivery through real gateway control path', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'failed',
|
|
retryCount: 3,
|
|
manualRetryCount: 1,
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
lastError: 'downstream client is not connected',
|
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
|
application: { cmppAccount: '100001' },
|
|
});
|
|
prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'pending',
|
|
retryCount: 0,
|
|
manualRetryCount: 2,
|
|
});
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
|
|
|
await service.requeueDownstreamDelivery('delivery-1');
|
|
|
|
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
|
'/downstream/receipt',
|
|
expect.objectContaining({
|
|
deliveryId: 'delivery-1',
|
|
account: '100001',
|
|
messageId: 'MSG-1',
|
|
}),
|
|
);
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_delivery_requeue',
|
|
resource: 'cmpp_downstream_delivery',
|
|
resourceId: 'delivery-1',
|
|
}),
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: 'delivery-1',
|
|
status: 'failed',
|
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
|
}),
|
|
data: expect.objectContaining({
|
|
status: 'manual_requeueing',
|
|
retryCount: 0,
|
|
manualRetryCount: { increment: 1 },
|
|
lastRetriedAt: expect.any(Date),
|
|
acknowledgedAt: null,
|
|
ackResult: null,
|
|
ackMessageId: null,
|
|
deliveredAt: null,
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'gateway.downstream_delivery_requeue',
|
|
detail: expect.objectContaining({
|
|
previousStatus: 'failed',
|
|
previousRetryCount: 3,
|
|
manualRetryCount: 2,
|
|
lastRetriedAt: expect.any(Date),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('rejects manual requeue while downstream acknowledgement is pending', async () => {
|
|
const { service, prisma } = createService();
|
|
service['postGatewayControl'] = jest.fn();
|
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
status: 'awaiting_ack',
|
|
payload: { account: '100001' },
|
|
application: { cmppAccount: '100001' },
|
|
});
|
|
|
|
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow(
|
|
'该记录正在等待客户端确认,不允许并发重投',
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled();
|
|
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('terminates a manual requeue when gateway reports it is unrecoverable', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findUnique
|
|
.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'failed',
|
|
retryCount: 3,
|
|
manualRetryCount: 0,
|
|
payload: { account: '100001', messageId: 'MSG-1', receiptStatus: 'delivered' },
|
|
application: { cmppAccount: '100001' },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'pending',
|
|
retryCount: 0,
|
|
retryEnabled: true,
|
|
});
|
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({
|
|
sent: false,
|
|
retryable: false,
|
|
reasonCode: 'MISSING_SUBMIT_SEQUENCE_ID',
|
|
errorMessage: '历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id,系统已终止重投',
|
|
});
|
|
|
|
await service.requeueDownstreamDelivery('delivery-1');
|
|
|
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: 'failed',
|
|
nextRetryAt: null,
|
|
lastError: expect.stringContaining('MISSING_SUBMIT_SEQUENCE_ID'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('supports batch requeue of downstream deliveries', async () => {
|
|
const { service } = createService();
|
|
service.requeueDownstreamDelivery = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({ id: 'delivery-1' })
|
|
.mockRejectedValueOnce(new Error('Gateway control delivery failed'));
|
|
|
|
await expect(service.batchRequeueDownstreamDeliveries(['delivery-1', 'delivery-2'])).resolves.toEqual({
|
|
total: 2,
|
|
successCount: 1,
|
|
failedCount: 1,
|
|
results: [
|
|
{ id: 'delivery-1', status: 'success' },
|
|
{ id: 'delivery-2', status: 'failed', errorMessage: 'Gateway control delivery failed' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects empty downstream batch requeue selection', async () => {
|
|
const { service } = createService();
|
|
await expect(service.batchRequeueDownstreamDeliveries([])).rejects.toThrow('请选择至少一条下游投递记录');
|
|
});
|
|
|
|
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{
|
|
id: 'record-1',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
amountCents: 3,
|
|
billingUnits: 1,
|
|
status: 'submitted',
|
|
cmppSubmitSequenceId: '701',
|
|
cmppRegisteredDelivery: true,
|
|
timeoutAt: null,
|
|
},
|
|
{
|
|
id: 'record-2',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: 'task-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-2',
|
|
phoneNumber: '13900000002',
|
|
amountCents: 3,
|
|
billingUnits: 1,
|
|
status: 'unknown',
|
|
cmppSubmitSequenceId: '702',
|
|
cmppRegisteredDelivery: true,
|
|
timeoutAt: null,
|
|
},
|
|
]);
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' })
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'bill-2', billingStatus: 'charged' });
|
|
|
|
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 2 });
|
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: { not: null },
|
|
OR: [
|
|
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } },
|
|
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
|
|
],
|
|
},
|
|
select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: true }),
|
|
take: 10000,
|
|
});
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'record-1', status: { in: ['submitted', 'unknown'] } },
|
|
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
|
|
});
|
|
expect(billing.refund).toHaveBeenCalledTimes(2);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
dedupeKey: 'receipt:record-1',
|
|
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 701 }),
|
|
}),
|
|
});
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
|
|
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
|
});
|
|
expect(prisma.$executeRaw).toHaveBeenCalled();
|
|
});
|
|
|
|
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
|
|
const prisma = createPrismaMock();
|
|
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) };
|
|
const { service } = createService(prisma, openApi);
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{
|
|
id: 'record-http-timeout',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: null,
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-HTTP-TIMEOUT',
|
|
phoneNumber: '13800000001',
|
|
amountCents: 0,
|
|
billingUnits: 1,
|
|
status: 'submitted',
|
|
cmppSubmitSequenceId: null,
|
|
cmppSubmitGroupMessageId: null,
|
|
cmppRegisteredDelivery: null,
|
|
timeoutAt: null,
|
|
},
|
|
]);
|
|
|
|
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 });
|
|
|
|
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
applicationId: 'app-1',
|
|
messageRecordId: 'record-http-timeout',
|
|
eventType: 'receipt',
|
|
payload: expect.objectContaining({
|
|
receiptStatus: 'undelivered',
|
|
rawStatus: 'EXPIRED',
|
|
errorCode: 'RECEIPT_TIMEOUT',
|
|
}),
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null },
|
|
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it('recovers timeout refund and downstream queueing when the prior scan stopped before setting the outbox marker', async () => {
|
|
const { service, prisma, billing } = createService();
|
|
prisma.smsBillingRecord.findFirst
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
|
{
|
|
id: 'record-timeout-recovery',
|
|
tenantId: 'tenant-1',
|
|
batchTaskId: null,
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-TIMEOUT-RECOVERY',
|
|
phoneNumber: '13800000001',
|
|
amountCents: 3,
|
|
billingUnits: 1,
|
|
status: 'timeout',
|
|
cmppSubmitSequenceId: '703',
|
|
cmppSubmitGroupMessageId: null,
|
|
cmppRegisteredDelivery: true,
|
|
timeoutAt: new Date('2026-08-01T00:00:00.000Z'),
|
|
},
|
|
]);
|
|
|
|
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 });
|
|
|
|
expect(billing.refund).toHaveBeenCalledTimes(1);
|
|
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
dedupeKey: 'receipt:record-timeout-recovery',
|
|
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 703 }),
|
|
}),
|
|
});
|
|
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
|
where: { id: 'record-timeout-recovery', status: 'timeout', timeoutReceiptQueuedAt: null },
|
|
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]);
|
|
service.markDownstreamDeliveryFailed = jest.fn().mockResolvedValue({ id: 'delivery-expired', status: 'failed' });
|
|
|
|
await expect(service.markExpiredDownstreamDeliveries(72)).resolves.toEqual({ failed: 1 });
|
|
|
|
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
status: 'pending',
|
|
OR: [
|
|
{ lastRetriedAt: null, createdAt: { lte: expect.any(Date) } },
|
|
{ lastRetriedAt: { lte: expect.any(Date) } },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(service.markDownstreamDeliveryFailed).toHaveBeenCalledWith(
|
|
'delivery-expired',
|
|
'下游投递排队超过 72 小时,系统自动终止重试',
|
|
'queue_timeout',
|
|
);
|
|
});
|
|
|
|
it('starts the automatic receipt-timeout scan after application startup', async () => {
|
|
jest.useFakeTimers();
|
|
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
|
const { service } = createService();
|
|
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
|
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
|
|
try {
|
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
|
|
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
|
|
service.onModuleInit();
|
|
await jest.advanceTimersByTimeAsync(60_000);
|
|
expect(scan).toHaveBeenCalledWith({});
|
|
expect(downstreamScan).toHaveBeenCalledWith();
|
|
await service.onModuleDestroy();
|
|
} finally {
|
|
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
|
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
|
|
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
|
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
|
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
});
|