fix: harden upstream and downstream receipt delivery
This commit is contained in:
@@ -36,6 +36,7 @@ function createPrismaMock() {
|
||||
sendRegion: '全国',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
protocol: 'CMPP',
|
||||
passwordCipher: 'secret',
|
||||
cmppVersion: '3.0',
|
||||
config: { serviceId: 'SMS' },
|
||||
@@ -240,6 +241,22 @@ function createPrismaMock() {
|
||||
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({
|
||||
@@ -1765,8 +1782,8 @@ describe('SendChainService', () => {
|
||||
where: { submitId: 'SUB-1' },
|
||||
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } },
|
||||
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
|
||||
@@ -1824,8 +1841,8 @@ describe('SendChainService', () => {
|
||||
expect(prisma.channelRouteRule.findFirst).not.toHaveBeenCalled();
|
||||
expect(billing.release).not.toHaveBeenCalled();
|
||||
expect(prisma.smsBatchTask.update).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1', status: { not: 'delivered' } },
|
||||
data: expect.objectContaining({
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'timeout',
|
||||
@@ -2680,6 +2697,108 @@ describe('SendChainService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
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: { submitId: 'SUB-1', gatewayMessageId: null },
|
||||
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('durably intakes an upstream receipt before asynchronous business matching', async () => {
|
||||
const { service, prisma } = createService();
|
||||
jest.spyOn(service as any, 'processUpstreamReceiptInboxRecord').mockResolvedValue(false);
|
||||
|
||||
await expect(service.intakeReceipt({
|
||||
messageId: 'receipt-9001',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'gateway-connection-2',
|
||||
sequenceId: 81,
|
||||
gatewayMessageId: '9001',
|
||||
phoneNumber: '13800000001',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
deliveredAt: '2026-07-25T15:01:00.000Z',
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
inboxId: 'receipt-inbox-1',
|
||||
}));
|
||||
|
||||
expect(prisma.upstreamReceiptInbox.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
incomingChannelId: 'channel-1',
|
||||
incomingConnectionId: 'gateway-connection-2',
|
||||
upstreamAccount: 'cmpp-account',
|
||||
upstreamHost: '127.0.0.1',
|
||||
upstreamPort: 17890,
|
||||
protocol: 'CMPP',
|
||||
protocolVersion: '3.0',
|
||||
gatewayMessageId: '9001',
|
||||
status: 'pending',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not downgrade an early terminal receipt when the aggregate submit result arrives later', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const terminalMessage = {
|
||||
id: 'record-1', messageId: 'MSG-1', tenantId: null, batchTaskId: null, applicationId: null,
|
||||
channelId: 'channel-1', submitId: 'SUB-1', gatewayMessageId: 'GW-SEG-1',
|
||||
phoneNumber: '13800000001', billingUnits: 1, amountCents: 0, status: 'failed',
|
||||
};
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue(terminalMessage);
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue(terminalMessage);
|
||||
prisma.smsMessageRecord.updateMany
|
||||
.mockResolvedValueOnce({ count: 0 })
|
||||
.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
submitId: 'SUB-1',
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'accepted',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } },
|
||||
data: expect.objectContaining({ status: 'submitted' }),
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: 'record-1', gatewayMessageId: null },
|
||||
data: expect.objectContaining({ gatewayMessageId: 'GW-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers a stale submit requeue with the same Redis idempotency key', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const stale = {
|
||||
@@ -2929,6 +3048,15 @@ describe('SendChainService', () => {
|
||||
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',
|
||||
@@ -2941,6 +3069,13 @@ describe('SendChainService', () => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user