Files
lislgosms/api/src/operations/operations.service.spec.ts
T

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