feat: complete cmpp gateway delivery recovery workflows

This commit is contained in:
hectorzhao
2026-07-08 16:30:06 +08:00
parent cc628d0214
commit 8144f08652
60 changed files with 8901 additions and 94 deletions
+529 -1
View File
@@ -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([