feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
@@ -33,6 +33,10 @@ function createPrismaMock() {
|
||||
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 }],
|
||||
};
|
||||
@@ -60,6 +64,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', 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',
|
||||
@@ -109,6 +114,7 @@ function createPrismaMock() {
|
||||
},
|
||||
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' }]),
|
||||
@@ -126,8 +132,16 @@ function createPrismaMock() {
|
||||
smsSubmitRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'submit-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),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
|
||||
},
|
||||
@@ -138,6 +152,103 @@ function createPrismaMock() {
|
||||
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,
|
||||
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 })),
|
||||
},
|
||||
gatewaySubmitDeadLetter: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'dead-1' }),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'dead-1',
|
||||
tenantId: 'tenant-1',
|
||||
streamMessageId: '1710000000000-0',
|
||||
submitId: 'SUB-1',
|
||||
messageId: 'MSG-1',
|
||||
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 }),
|
||||
},
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
upsert: jest.fn().mockResolvedValue({
|
||||
id: 'recover-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
account: '100001',
|
||||
state: 'waiting_connection',
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
attemptCount: 2,
|
||||
failureCategory: 'client_disconnected',
|
||||
nextRetryAt: new Date('2026-07-08T12:10:00.000Z'),
|
||||
lastError: 'downstream client is not connected',
|
||||
}),
|
||||
},
|
||||
smsBillingRecord: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
@@ -148,12 +259,16 @@ function createPrismaMock() {
|
||||
accountTransaction: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,7 +293,10 @@ function createService(prisma = createPrismaMock()) {
|
||||
task: { id: 'risk-task-1' },
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
return { service: new SendChainService(prisma as never, billing, riskReview), prisma, billing, riskReview };
|
||||
const service = new SendChainService(prisma as never, billing, riskReview);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
@@ -371,8 +489,11 @@ describe('SendChainService', () => {
|
||||
phoneNumber: '13800000001',
|
||||
route: expect.objectContaining({ channelCode: 'CMPP-A', rateLimitPerSecond: 100 }),
|
||||
cmpp: expect.objectContaining({ serviceId: 'SMS', srcId: '10690000' }),
|
||||
upstream: expect.objectContaining({ gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'cmpp-account' }),
|
||||
}),
|
||||
);
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'MSG-1' }));
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
||||
});
|
||||
|
||||
it('updates submit result status, charges billing, and task progress', async () => {
|
||||
@@ -386,6 +507,24 @@ describe('SendChainService', () => {
|
||||
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({
|
||||
@@ -401,6 +540,18 @@ describe('SendChainService', () => {
|
||||
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', 'requeued'] },
|
||||
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
|
||||
},
|
||||
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
@@ -520,6 +671,88 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{
|
||||
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('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'submit-timeout-1',
|
||||
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
|
||||
},
|
||||
{
|
||||
id: 'submit-timeout-2',
|
||||
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.handleReceipt({
|
||||
messageId: 'receipt-ambiguous',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-AMBIGUOUS',
|
||||
phoneNumber: '13800000001',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
}),
|
||||
).rejects.toThrow('SMS message record not found');
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks submit when signature is not approved on the selected channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -635,6 +868,301 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 dead letters and 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');
|
||||
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
|
||||
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dead-1' },
|
||||
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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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_sync',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
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('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();
|
||||
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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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 72 hour unknown receipts as timeout', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
|
||||
Reference in New Issue
Block a user