feat: integrate analytics and fragment receipt improvements
This commit is contained in:
@@ -23,6 +23,9 @@ function createPrismaMock() {
|
||||
submitId: 'SUB-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
channelId: 'channel-1',
|
||||
cmppSubmitSequenceId: '101',
|
||||
cmppSubmitGroupMessageId: null,
|
||||
cmppRegisteredDelivery: true,
|
||||
template: { signature: { id: 'sig-1', name: '签名' } },
|
||||
};
|
||||
const channel = {
|
||||
@@ -109,6 +112,9 @@ function createPrismaMock() {
|
||||
smsDrainageInfo: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
drainageDetectionRule: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
smsSendTask: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
|
||||
@@ -688,7 +694,7 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
|
||||
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',
|
||||
@@ -705,14 +711,14 @@ describe('SendChainService', () => {
|
||||
})).resolves.toBeDefined();
|
||||
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
|
||||
data: expect.objectContaining({ status: 'ready', rejectReason: null }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
|
||||
drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined,
|
||||
})],
|
||||
});
|
||||
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
||||
@@ -2576,6 +2582,18 @@ describe('SendChainService', () => {
|
||||
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',
|
||||
@@ -2621,10 +2639,22 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-long' },
|
||||
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
||||
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('creates and sends only one downstream final receipt under concurrent completion', async () => {
|
||||
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 }) => {
|
||||
@@ -2690,6 +2720,18 @@ describe('SendChainService', () => {
|
||||
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',
|
||||
@@ -2754,12 +2796,9 @@ describe('SendChainService', () => {
|
||||
}),
|
||||
});
|
||||
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',
|
||||
}),
|
||||
data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending' }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3801,8 +3840,8 @@ describe('SendChainService', () => {
|
||||
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 },
|
||||
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 },
|
||||
{ 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' })
|
||||
@@ -3812,10 +3851,12 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: { not: null },
|
||||
status: { in: ['submitted', 'unknown'] },
|
||||
submittedAt: { lte: expect.any(Date) },
|
||||
OR: [
|
||||
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } },
|
||||
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
|
||||
],
|
||||
},
|
||||
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
||||
select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: true }),
|
||||
take: 10000,
|
||||
});
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
@@ -3823,9 +3864,95 @@ describe('SendChainService', () => {
|
||||
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.smsBatchTask.update).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',
|
||||
}),
|
||||
}));
|
||||
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' }]);
|
||||
|
||||
Reference in New Issue
Block a user