feat: harden platform workflows and UI governance
This commit is contained in:
@@ -84,8 +84,10 @@ function createPrismaMock() {
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
content: 'hello',
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
}),
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'tpl-1',
|
||||
@@ -112,6 +114,7 @@ function createPrismaMock() {
|
||||
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' }),
|
||||
@@ -214,6 +217,9 @@ function createPrismaMock() {
|
||||
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' },
|
||||
@@ -257,6 +263,7 @@ function createPrismaMock() {
|
||||
}),
|
||||
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),
|
||||
@@ -364,6 +371,10 @@ describe('SendChainService', () => {
|
||||
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'],
|
||||
@@ -384,16 +395,17 @@ describe('SendChainService', () => {
|
||||
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',
|
||||
signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
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', updatedAt: new Date('2026-07-01') },
|
||||
{ id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') },
|
||||
{ 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'],
|
||||
content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
@@ -401,6 +413,81 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 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'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', 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: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
|
||||
})],
|
||||
});
|
||||
expect(riskReview.evaluateTask).not.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 });
|
||||
@@ -437,6 +524,102 @@ describe('SendChainService', () => {
|
||||
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 { 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';
|
||||
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;
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels scheduled tasks before dispatch', async () => {
|
||||
@@ -1155,7 +1338,7 @@ describe('SendChainService', () => {
|
||||
}));
|
||||
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeued'] },
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
|
||||
},
|
||||
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
|
||||
@@ -1734,13 +1917,16 @@ describe('SendChainService', () => {
|
||||
operatorId: 'user-1',
|
||||
});
|
||||
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-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.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dead-1' },
|
||||
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'dead-1', status: 'requeueing' },
|
||||
data: expect.objectContaining({
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
@@ -1778,6 +1964,103 @@ describe('SendChainService', () => {
|
||||
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('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();
|
||||
|
||||
@@ -2053,6 +2336,7 @@ describe('SendChainService', () => {
|
||||
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' },
|
||||
@@ -2086,9 +2370,14 @@ describe('SendChainService', () => {
|
||||
resourceId: 'delivery-1',
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
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: 'pending',
|
||||
status: 'manual_requeueing',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: expect.any(Date),
|
||||
@@ -2122,7 +2411,7 @@ describe('SendChainService', () => {
|
||||
});
|
||||
|
||||
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
|
||||
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
|
||||
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled();
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2231,11 +2520,13 @@ describe('SendChainService', () => {
|
||||
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 { 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';
|
||||
service.onModuleInit();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(scan).toHaveBeenCalledWith({});
|
||||
@@ -2244,6 +2535,8 @@ describe('SendChainService', () => {
|
||||
} 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;
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user