fix: close receipt delivery workflows

This commit is contained in:
hectorzhao
2026-07-24 14:40:57 +08:00
parent 2ee39056eb
commit 91f04f5288
22 changed files with 829 additions and 48 deletions
+123 -6
View File
@@ -322,7 +322,10 @@ function createPrismaMock() {
return prisma;
}
function createService(prisma = createPrismaMock()) {
function createService(
prisma = createPrismaMock(),
openApi?: { queueWebhookEvent: jest.Mock },
) {
const billing = {
estimateSmsCost: jest.fn().mockReturnValue({
billingUnitsPerMessage: 1,
@@ -347,7 +350,7 @@ function createService(prisma = createPrismaMock()) {
reviewReason: '企业应用已配置模板不匹配进入人工审核',
}),
} as unknown as RiskReviewService;
const service = new SendChainService(prisma as never, billing, riskReview);
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
return { service, prisma, billing, riskReview };
@@ -780,7 +783,6 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
@@ -788,7 +790,7 @@ describe('SendChainService', () => {
})).rejects.toThrow('CMPP interface is disabled for this application');
});
it('records and acknowledges Gateway submit with a failure receipt when the application interface was disabled after bind', async () => {
it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
@@ -801,6 +803,15 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: { enabled: false },
});
await expect(service.submitInboundMessage({
account: '100001',
@@ -813,9 +824,37 @@ describe('SendChainService', () => {
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', deliveryType: 'receipt', status: 'pending' }),
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => {
const prisma = createPrismaMock();
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ queued: true }) };
const { service } = createService(prisma, openApi);
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: { enabled: true },
});
await service['queueAndTryDownstreamDelivery']({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'receipt',
payload: { receiptStatus: 'delivered' },
});
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-1',
eventType: 'receipt',
}));
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
it('splits every destination in one inbound CMPP Submit into an independent real message record', async () => {
@@ -2199,6 +2238,84 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
});
it('marks a long message failed when a non-primary segment returns an explicit failure', async () => {
const { service, prisma, billing } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue({
id: 'record-long',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-LONG-FAIL',
submitId: 'SUB-LONG-FAIL',
phoneNumber: '18821203795',
channelId: 'channel-1',
gatewayMessageId: 'GW-SEG-1',
status: 'submitted',
billingUnits: 2,
amountCents: 6,
unitPrice: 3,
});
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
id: 'segment-2',
messageRecordId: 'record-long',
submitRecordId: 'submit-long',
submitId: 'SUB-LONG-FAIL',
channelId: 'channel-1',
gatewayMessageId: 'GW-SEG-2',
segmentIndex: 2,
segmentTotal: 2,
});
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() },
]);
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' });
prisma.channelRouteRule.findFirst.mockResolvedValue({
id: 'route-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
carrier: 'mobile',
group: {
id: 'group-1',
carrier: 'mobile',
status: 'active',
retryEnabled: false,
retryTimeLimitHours: 72,
retryTimeLimitMinutes: 4320,
items: [],
},
});
await service.handleReceipt({
messageId: 'MSG-LONG-FAIL',
channelId: 'channel-1',
gatewayMessageId: 'GW-SEG-2',
phoneNumber: '18821203795',
receiptStatus: 'undelivered',
rawStatus: 'YL:1014',
});
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
where: { id: 'record-long' },
data: expect.objectContaining({
status: 'failed',
receiptStatus: 'undelivered',
receiptRawStatus: 'YL:1014',
}),
});
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-long',
deliveryType: 'receipt',
status: 'pending',
}),
});
});
it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findUnique