675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
import { OperationsService } from './operations.service';
|
|
|
|
function createPrismaMock() {
|
|
return {
|
|
smsBatchTask: {
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
|
count: jest.fn().mockResolvedValue(3),
|
|
},
|
|
smsSendTask: {
|
|
count: jest.fn().mockResolvedValue(2),
|
|
},
|
|
smsMessageRecord: {
|
|
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
|
|
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
|
|
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
|
|
},
|
|
smsReceiptRecord: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
smsUplinkMessage: {
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'uplink-1', messageId: 'MSG-1' }]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
},
|
|
smsBillingRecord: {
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'bill-1', messageId: 'MSG-1' }]),
|
|
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
|
|
},
|
|
accountTransaction: {
|
|
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20 } }),
|
|
},
|
|
tenantAccount: {
|
|
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]),
|
|
},
|
|
rechargeOrder: {
|
|
findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]),
|
|
},
|
|
smsTemplate: {
|
|
count: jest.fn().mockResolvedValue(1),
|
|
},
|
|
smsSignature: {
|
|
count: jest.fn().mockResolvedValue(1),
|
|
},
|
|
smsDrainageInfo: {
|
|
count: jest.fn().mockResolvedValue(0),
|
|
},
|
|
enterpriseCertification: {
|
|
count: jest.fn().mockResolvedValue(1),
|
|
},
|
|
smsApplication: {
|
|
findMany: jest.fn().mockResolvedValue([
|
|
{ id: 'app-1', name: '应用A' },
|
|
{ id: 'app-2', name: '应用B' },
|
|
]),
|
|
},
|
|
cmppConnectionState: {
|
|
groupBy: jest.fn().mockResolvedValue([{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
|
},
|
|
operationLog: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'log-1',
|
|
tenantId: 'tenant-1',
|
|
tenant: { name: '租户A' },
|
|
user: { displayName: '运营' },
|
|
action: 'billing.manual_recharge',
|
|
resource: 'recharge_order',
|
|
resourceId: 'order-1',
|
|
detail: { amountCents: 1000 },
|
|
createdAt: new Date('2026-07-02T01:00:00.000Z'),
|
|
}]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
|
|
},
|
|
gatewaySubmitDeadLetter: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'dead-1',
|
|
streamMessageId: '1710000000000-0',
|
|
status: 'pending',
|
|
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
|
failureMessage: 'network down',
|
|
rawPayload: '{"upstream":{"passwordCipher":"secret"}}',
|
|
commandPayload: { upstream: { account: 'sp', passwordCipher: 'secret' } },
|
|
tenant: { name: '租户A' },
|
|
application: { name: '应用A' },
|
|
channel: { code: 'CMPP-A' },
|
|
}]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
|
|
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
|
|
},
|
|
gatewayDownstreamRecoveryStatus: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'recover-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',
|
|
tenant: { name: '租户A' },
|
|
application: { name: '应用A' },
|
|
}]),
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'recover-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'),
|
|
lastAttemptAt: new Date('2026-07-08T12:00:00.000Z'),
|
|
lastSuccessAt: null,
|
|
lastFailureAt: new Date('2026-07-08T11:58:00.000Z'),
|
|
lastError: 'downstream client is not connected',
|
|
lastSkipReason: 'waiting for reconnect',
|
|
gatewayInstanceId: 'gw-01',
|
|
createdAt: new Date('2026-07-08T11:50:00.000Z'),
|
|
updatedAt: new Date('2026-07-08T12:00:00.000Z'),
|
|
tenant: { name: '租户A' },
|
|
application: { name: '应用A' },
|
|
}),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
groupBy: jest.fn().mockResolvedValue([
|
|
{ failureCategory: 'client_disconnected', _count: { _all: 1 } },
|
|
]),
|
|
},
|
|
smsMessageSegmentAudit: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'segment-1',
|
|
messageRecordId: 'record-1',
|
|
submitId: 'SUB-1',
|
|
segmentTotal: 2,
|
|
segmentIndex: 1,
|
|
sequenceId: 7,
|
|
gatewayMessageId: 'GW-1-A',
|
|
submitStatus: 'accepted',
|
|
receiptStatus: 'delivered',
|
|
channel: { name: '通道A' },
|
|
}]),
|
|
},
|
|
cmppDownstreamDelivery: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'delivery-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
messageId: 'MSG-1',
|
|
deliveryType: 'receipt',
|
|
status: 'failed',
|
|
payload: { account: '100001', phoneNumber: '13800000001' },
|
|
retryCount: 10,
|
|
lastError: 'client offline',
|
|
tenant: { name: '租户A' },
|
|
application: { name: '应用A' },
|
|
messageRecord: { messageId: 'MSG-1' },
|
|
}]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
groupBy: jest.fn().mockResolvedValue([
|
|
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
|
|
{ deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } },
|
|
{ deliveryType: 'receipt', status: 'delivered', _count: { _all: 6 } },
|
|
{ deliveryType: 'uplink', status: 'pending', _count: { _all: 1 } },
|
|
{ deliveryType: 'uplink', status: 'delivered', _count: { _all: 2 } },
|
|
{ applicationId: 'app-1', status: 'pending', _count: { _all: 2 } },
|
|
{ applicationId: 'app-1', status: 'failed', _count: { _all: 1 } },
|
|
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
|
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
|
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
|
]),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('OperationsService', () => {
|
|
it('keeps task progress limited to client-created batch tasks', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' });
|
|
|
|
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' },
|
|
}));
|
|
});
|
|
|
|
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await service.listMessages({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
channelId: 'channel-1',
|
|
channelKeyword: '移动通道',
|
|
taskId: 'task-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
contentKeyword: '验证码',
|
|
queuedAtFrom: '2026-07-01',
|
|
queuedAtTo: '2026-07-02',
|
|
status: 'delivered',
|
|
});
|
|
|
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
channelId: 'channel-1',
|
|
batchTaskId: 'task-1',
|
|
messageId: 'MSG-1',
|
|
phoneNumber: '13800000001',
|
|
status: 'delivered',
|
|
content: { contains: '验证码', mode: 'insensitive' },
|
|
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
|
|
queuedAt: {
|
|
gte: new Date('2026-07-01T00:00:00+08:00'),
|
|
lte: new Date('2026-07-02T23:59:59.999+08:00'),
|
|
},
|
|
},
|
|
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
|
orderBy: { queuedAt: 'desc' },
|
|
});
|
|
});
|
|
|
|
it('returns uplink messages with tenant and channel display data', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' });
|
|
|
|
expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({
|
|
where: { tenantId: 'tenant-1', channelId: 'channel-1', applicationId: undefined, phoneNumber: undefined, content: undefined, receivedAt: undefined },
|
|
include: {
|
|
tenant: true,
|
|
application: true,
|
|
channel: true,
|
|
messageRecord: { include: { application: true } },
|
|
matchCandidates: {
|
|
include: {
|
|
tenant: true,
|
|
application: true,
|
|
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
|
},
|
|
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
|
},
|
|
},
|
|
orderBy: { receivedAt: 'desc' },
|
|
take: 500,
|
|
});
|
|
});
|
|
|
|
it('builds dashboard and statistics aggregates', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.cmppDownstreamDelivery.count = jest.fn()
|
|
.mockResolvedValueOnce(3)
|
|
.mockResolvedValueOnce(2)
|
|
.mockResolvedValueOnce(8)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(2);
|
|
prisma.accountTransaction.aggregate.mockResolvedValueOnce({ _count: { _all: 2 }, _sum: { amountCents: 10 } });
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.dashboard({ tenantId: 'tenant-1' })).resolves.toEqual(
|
|
expect.objectContaining({
|
|
taskCount: 3,
|
|
uplinkCount: 1,
|
|
pendingAuditCount: 5,
|
|
pendingAudits: {
|
|
enterpriseCertifications: 1,
|
|
smsAudits: 2,
|
|
signatures: 1,
|
|
drainageInfos: 0,
|
|
templates: 1,
|
|
total: 5,
|
|
},
|
|
today: expect.objectContaining({ returnedCents: 10 }),
|
|
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
|
downstreamDeliverySummary: expect.objectContaining({
|
|
pending: 3,
|
|
failed: 2,
|
|
delivered: 8,
|
|
stalledPending: 1,
|
|
stalledAck: 1,
|
|
recentFailed: 2,
|
|
alertCount: 4,
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(4, {
|
|
where: {
|
|
tenantId: 'tenant-1',
|
|
status: 'pending',
|
|
OR: [
|
|
{ lastRetriedAt: null, createdAt: { lte: expect.any(Date) } },
|
|
{ lastRetriedAt: { lte: expect.any(Date) } },
|
|
],
|
|
},
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(5, {
|
|
where: { tenantId: 'tenant-1', status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } },
|
|
});
|
|
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(6, {
|
|
where: {
|
|
tenantId: 'tenant-1',
|
|
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
|
updatedAt: { gte: expect.any(Date) },
|
|
},
|
|
});
|
|
expect(prisma.accountTransaction.aggregate).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: 'tenant-1',
|
|
createdAt: { gte: expect.any(Date) },
|
|
OR: [
|
|
{ transactionType: 'refunded' },
|
|
{ transactionType: 'released', relatedType: 'sms_message_record' },
|
|
],
|
|
},
|
|
_sum: { amountCents: true },
|
|
_count: { _all: true },
|
|
});
|
|
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
|
|
|
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith({
|
|
by: ['applicationId'],
|
|
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
|
_count: { _all: true },
|
|
_sum: { amountCents: true, billingUnits: true },
|
|
});
|
|
});
|
|
|
|
it('returns trace details and reconciliation diffs', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.trace({ tenantId: 'tenant-1', taskId: 'task-1', messageId: 'MSG-1' })).resolves.toEqual({
|
|
messages: [{ messageId: 'MSG-1' }],
|
|
billingRecords: [{ id: 'bill-1', messageId: 'MSG-1' }],
|
|
uplinks: [{ id: 'uplink-1', messageId: 'MSG-1' }],
|
|
});
|
|
await expect(service.reconciliation({ tenantId: 'tenant-1', taskId: 'task-1' })).resolves.toEqual(
|
|
expect.objectContaining({
|
|
diff: {
|
|
messageVsBillingAmountCents: 0,
|
|
billingVsTransactionAmountCents: 0,
|
|
messageVsBillingUnits: 0,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns paginated operation logs with normalized detail cards', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.systemLogs({ tenantId: 'tenant-1', keyword: '充值', page: 1, pageSize: 5 })).resolves.toEqual(
|
|
expect.objectContaining({
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 5,
|
|
modules: ['recharge_order'],
|
|
items: [
|
|
expect.objectContaining({
|
|
level: 'success',
|
|
tenant: '租户A',
|
|
module: 'recharge_order',
|
|
action: 'billing.manual_recharge',
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('applies operation-log level filters before database pagination', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await service.systemLogs({ level: 'error', page: 2, pageSize: 5 });
|
|
|
|
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
AND: expect.objectContaining({ OR: expect.any(Array) }),
|
|
}),
|
|
skip: 5,
|
|
take: 5,
|
|
}));
|
|
expect(prisma.operationLog.count).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }),
|
|
});
|
|
});
|
|
|
|
it('caps legacy audit-log reads with pagination', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 100,
|
|
}));
|
|
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
|
|
});
|
|
|
|
it('returns paginated gateway submit dead letters', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.listGatewaySubmitDeadLetters({
|
|
tenantId: 'tenant-1',
|
|
status: 'pending',
|
|
keyword: 'SUBMIT',
|
|
page: 1,
|
|
pageSize: 10,
|
|
})).resolves.toEqual({
|
|
items: [expect.objectContaining({
|
|
id: 'dead-1',
|
|
status: 'pending',
|
|
rawPayloadAvailable: true,
|
|
commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } },
|
|
})],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 10,
|
|
summary: {
|
|
pending: 1,
|
|
requeueing: 0,
|
|
requeued: 0,
|
|
resolved: 0,
|
|
oldestPendingAt: new Date('2026-07-08T12:00:00.000Z'),
|
|
},
|
|
});
|
|
|
|
expect(prisma.gatewaySubmitDeadLetter.findMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
status: 'pending',
|
|
}),
|
|
include: { tenant: true, application: true, channel: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
|
|
it('returns paginated downstream deliveries', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.listDownstreamDeliveries({
|
|
tenantId: 'tenant-1',
|
|
deliveryType: 'receipt',
|
|
status: 'failed',
|
|
keyword: '1380',
|
|
createdAtFrom: '2026-07-01',
|
|
createdAtTo: '2026-07-15',
|
|
page: 1,
|
|
pageSize: 10,
|
|
})).resolves.toEqual({
|
|
items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 10,
|
|
});
|
|
|
|
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
deliveryType: 'receipt',
|
|
status: 'failed',
|
|
createdAt: {
|
|
gte: new Date('2026-07-01T00:00:00.000+08:00'),
|
|
lte: new Date('2026-07-15T23:59:59.999+08:00'),
|
|
},
|
|
}),
|
|
include: { tenant: true, application: true, messageRecord: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
|
|
it('builds downstream delivery dashboard aggregates', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.cmppDownstreamDelivery.count = jest.fn()
|
|
.mockResolvedValueOnce(12)
|
|
.mockResolvedValueOnce(3)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(8)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(2)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(0);
|
|
prisma.cmppDownstreamDelivery.groupBy = jest.fn()
|
|
.mockResolvedValueOnce([
|
|
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
|
|
{ deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } },
|
|
{ deliveryType: 'receipt', status: 'delivered', _count: { _all: 6 } },
|
|
{ deliveryType: 'uplink', status: 'pending', _count: { _all: 1 } },
|
|
{ deliveryType: 'uplink', status: 'delivered', _count: { _all: 2 } },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ applicationId: 'app-1', status: 'pending', _count: { _all: 2 } },
|
|
{ applicationId: 'app-1', status: 'failed', _count: { _all: 1 } },
|
|
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
|
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
|
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ applicationId: 'app-1', _count: { _all: 1 } },
|
|
{ applicationId: 'app-2', _count: { _all: 1 } },
|
|
]);
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.downstreamDeliveryDashboard({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
deliveryType: 'all',
|
|
})).resolves.toEqual({
|
|
summary: {
|
|
total: 12,
|
|
pending: 3,
|
|
awaitingAck: 0,
|
|
delivered: 8,
|
|
failed: 1,
|
|
unconfirmed: 0,
|
|
rejected: 0,
|
|
stalledPending: 1,
|
|
stalledAck: 0,
|
|
recentFailed: 1,
|
|
alertCount: 2,
|
|
},
|
|
typeBreakdown: [
|
|
{ deliveryType: 'receipt', total: 9, pending: 2, awaitingAck: 0, delivered: 6, failed: 1, unconfirmed: 0, rejected: 0 },
|
|
{ deliveryType: 'uplink', total: 3, pending: 1, awaitingAck: 0, delivered: 2, failed: 0, unconfirmed: 0, rejected: 0 },
|
|
],
|
|
retryBuckets: [
|
|
{ label: '0次', count: 2 },
|
|
{ label: '1-3次', count: 1 },
|
|
{ label: '4次及以上', count: 0 },
|
|
],
|
|
topApplications: [
|
|
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 1 },
|
|
{ applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 },
|
|
],
|
|
});
|
|
|
|
expect(prisma.cmppDownstreamDelivery.groupBy).toHaveBeenNthCalledWith(3, {
|
|
by: ['applicationId'],
|
|
where: {
|
|
AND: [
|
|
{ tenantId: 'tenant-1', applicationId: 'app-1', deliveryType: undefined, createdAt: undefined },
|
|
{
|
|
OR: [
|
|
{
|
|
status: 'pending',
|
|
OR: [
|
|
{ lastRetriedAt: null, createdAt: { lte: expect.any(Date) } },
|
|
{ lastRetriedAt: { lte: expect.any(Date) } },
|
|
],
|
|
},
|
|
{ status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } },
|
|
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: expect.any(Date) } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
_count: { _all: true },
|
|
});
|
|
});
|
|
|
|
it('returns paginated downstream recovery statuses', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.gatewayDownstreamRecoveryStatus.count = jest.fn()
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(1)
|
|
.mockResolvedValueOnce(1);
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.listDownstreamRecoveryStatuses({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
state: 'waiting_connection',
|
|
failureCategory: 'client_disconnected',
|
|
keyword: '100001',
|
|
page: 1,
|
|
pageSize: 10,
|
|
})).resolves.toEqual({
|
|
items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 10,
|
|
summary: {
|
|
total: 1,
|
|
running: 0,
|
|
success: 0,
|
|
failed: 0,
|
|
waitingConnection: 1,
|
|
backoff: 1,
|
|
failureCategories: [
|
|
{ category: 'client_disconnected', count: 1 },
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns downstream recovery status detail', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.getDownstreamRecoveryStatus('recover-1')).resolves.toEqual(
|
|
expect.objectContaining({
|
|
id: 'recover-1',
|
|
account: '100001',
|
|
gatewayInstanceId: 'gw-01',
|
|
}),
|
|
);
|
|
|
|
expect(prisma.gatewayDownstreamRecoveryStatus.findUnique).toHaveBeenCalledWith({
|
|
where: { id: 'recover-1' },
|
|
include: { tenant: true, application: true },
|
|
});
|
|
});
|
|
|
|
it('exports downstream recovery statuses as csv rows', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.exportDownstreamRecoveryStatuses({
|
|
tenantId: 'tenant-1',
|
|
state: 'waiting_connection',
|
|
keyword: '100001',
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
total: 1,
|
|
fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/),
|
|
content: expect.stringContaining('100001'),
|
|
}));
|
|
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
failureCategory: undefined,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('returns message segment audit rows', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new OperationsService(prisma as never);
|
|
|
|
await expect(service.listMessageSegmentAudits({ messageRecordId: 'record-1' })).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: 'segment-1',
|
|
segmentIndex: 1,
|
|
gatewayMessageId: 'GW-1-A',
|
|
}),
|
|
]);
|
|
|
|
expect(prisma.smsMessageSegmentAudit.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
messageRecordId: 'record-1',
|
|
messageRecord: undefined,
|
|
},
|
|
include: { channel: true, submitRecord: true },
|
|
orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }],
|
|
});
|
|
});
|
|
});
|