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' }], }); }); });