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

1525 lines
51 KiB
TypeScript

import { OperationsService } from './operations.service';
function createPrismaMock() {
return {
$queryRaw: jest.fn().mockResolvedValue([]),
user: {
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
},
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '企业A' }),
},
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' }]),
findUnique: jest.fn().mockResolvedValue({
id: 'message-1',
messageId: 'MSG-1',
submitRecords: [],
receiptRecords: [],
downstreamDeliveries: [],
}),
count: jest.fn().mockResolvedValue(51),
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 }]),
},
reportMaterialImportItem: { count: jest.fn().mockResolvedValue(0) },
smsTemplate: {
count: jest.fn().mockResolvedValue(1),
},
smsSignature: {
count: jest.fn().mockResolvedValue(1),
},
smsDrainageInfo: {
count: jest.fn().mockResolvedValue(0),
},
enterpriseCertification: {
count: jest.fn().mockResolvedValue(1),
findFirst: jest.fn().mockResolvedValue({ id: 'certification-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') }),
},
smsReceiptAnomaly: {
findMany: jest.fn().mockResolvedValue([
{
id: 'receipt-anomaly-1',
anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1',
anomalyType: 'aggregate_success_then_failure',
status: 'pending',
occurrenceCount: 1,
firstOccurredAt: new Date('2026-08-06T01:00:00.000Z'),
lastOccurredAt: new Date('2026-08-06T01:00:00.000Z'),
tenant: { name: '租户A' },
application: { name: '应用A' },
channel: { name: '通道A' },
messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' },
submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' },
receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' },
},
]),
count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01: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, carrier, 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: '验证码',
carrier: 'mobile',
queuedAtFrom: '2026-07-01',
queuedAtTo: '2026-07-02',
status: 'delivered',
hasDrainage: 'true',
});
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
where: {
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
batchTaskId: 'task-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
status: 'delivered',
hasDrainageContent: true,
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: { include: { channel: true, channelGroup: true } },
receiptRecords: { include: { channel: true } },
downstreamDeliveries: {
where: { deliveryType: 'receipt' },
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
},
},
orderBy: { queuedAt: 'desc' },
});
});
it('treats null and nonstandard carrier values as unrecognized', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.listMessages({ carrier: 'unknown' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
AND: [
{
OR: [
{ carrier: null },
{
carrier: {
notIn: [
'mobile',
'cmcc',
'移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
],
},
},
],
},
],
}),
}),
);
});
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.listMessages({ status: 'submit_failed' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }],
}),
}),
);
await service.listMessages({ status: 'failed' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }),
}),
);
});
it('paginates message summaries without preloading detail relations', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.listMessagesPage({ tenantId: 'tenant-1', page: 2, pageSize: 25 })).resolves.toEqual({
items: [{ messageId: 'MSG-1' }],
total: 51,
page: 2,
pageSize: 25,
});
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }),
skip: 25,
take: 25,
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
select: expect.objectContaining({
id: true,
content: true,
hasDrainageContent: true,
tenant: { select: { id: true, name: true } },
}),
}),
);
const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0];
expect(call.select).not.toHaveProperty('submitRecords');
expect(call.select).not.toHaveProperty('receiptRecords');
expect(call.select).not.toHaveProperty('downstreamDeliveries');
expect(prisma.smsMessageRecord.count).toHaveBeenCalledWith({
where: expect.objectContaining({ tenantId: 'tenant-1' }),
});
});
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('loads heavy message relations only for one requested detail', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' }));
expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'message-1' },
include: expect.objectContaining({
submitRecords: expect.any(Object),
receiptRecords: expect.any(Object),
downstreamDeliveries: expect.any(Object),
}),
}),
);
});
it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => {
const prisma = createPrismaMock();
prisma.smsUplinkMessage.findMany.mockResolvedValue([
{
id: 'uplink-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: null,
gatewayMessageId: '8412634832294102675',
phoneNumber: '13800000001',
destId: '10690000',
content: 'TD',
matchStatus: 'matched',
matchReason: '手机号 72 小时窗口唯一匹配',
receivedAt: new Date('2026-08-26T01:00:00.000Z'),
createdAt: new Date('2026-08-26T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
messageRecord: {
id: 'record-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
content: '通知内容',
billingUnits: 1,
amountCents: 325,
status: 'delivered',
queuedAt: new Date('2026-08-25T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
},
matchCandidates: [],
},
]);
const service = new OperationsService(prisma as never);
const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' });
expect(uplink).toMatchObject({
messageId: null,
gatewayMessageId: '8412634832294102675',
messageRecordId: 'record-1',
matchStatus: 'matched',
messageRecord: { id: 'record-1', messageId: 'MSG-1' },
});
});
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
const prisma = createPrismaMock();
prisma.smsMessageRecord.findMany.mockResolvedValue([
{
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
channelId: 'channel-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
carrier: 'mobile',
province: '上海',
content: '验证码1234',
billingUnits: 1,
amountCents: 352,
status: 'delivered',
queuedAt: new Date('2026-07-21T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A', secretHash: 'secret' },
tenant: { id: 'tenant-1', name: '企业A' },
channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 },
submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }],
receiptRecords: [
{
id: 'receipt-1',
messageId: 'MSG-1',
gatewayMessageId: 'GW-1',
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
errorCode: null,
errorMessage: null,
deliveredAt: new Date('2026-07-21T01:00:05.000Z'),
createdAt: new Date('2026-07-21T01:00:05.000Z'),
},
],
},
]);
const service = new OperationsService(prisma as never);
const [message] = await service.listClientMessages({ tenantId: 'tenant-1' });
expect(message).toMatchObject({
id: 'record-1',
messageId: 'MSG-1',
carrier: 'mobile',
province: '上海',
application: { id: 'app-1', name: '应用A' },
receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })],
});
expect(message).not.toHaveProperty('tenant');
expect(message).not.toHaveProperty('tenantId');
expect(message).not.toHaveProperty('channel');
expect(message).not.toHaveProperty('channelId');
expect(message).not.toHaveProperty('submitRecords');
expect(JSON.stringify(message)).not.toMatch(/passwordCipher|gatewayMessageId|unitPrice|supplier|cipher/);
});
it('returns a client dashboard without gateway state or supplier channel secrets', async () => {
const prisma = createPrismaMock();
prisma.smsBatchTask.findMany.mockResolvedValue([
{
id: 'task-1',
taskNo: 'BATCH-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
phoneTotal: 1,
status: 'finished',
createdAt: new Date('2026-07-21T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }],
},
]);
const service = new OperationsService(prisma as never);
const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' });
expect(dashboard.gatewayConnections).toEqual([]);
expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]);
expect(dashboard.clientOverview).toEqual({
enterpriseName: '企业A',
certificationStatus: 'certified',
signatureCount: 1,
pendingBatchTaskCount: 3,
});
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', sourceType: 'client', status: 'pending_review' },
});
expect(prisma.enterpriseCertification.findFirst).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: 'approved' },
select: { id: true },
});
expect(prisma.smsSignature.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
});
expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/);
});
it('builds dashboard and statistics aggregates', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw
.mockResolvedValueOnce([
{
tenantId: 'tenant-1',
tenantName: '租户A',
todaySpendCents: 24000n,
balanceCents: 1000000n,
creditCents: 50000n,
},
])
.mockResolvedValueOnce([
{
segmentCount: 20n,
deliveredSegmentCount: 18n,
billedCents: 360n,
costCents: 216n,
},
])
.mockResolvedValueOnce([
{ hour: 9, submittedCount: 12n, successCount: 10n },
{ hour: 10, submittedCount: 5n, successCount: 4n },
])
.mockResolvedValueOnce([
{ category: 'templates', count: 3n, averageProcessingMs: 90_000n },
{ category: 'signatures', count: 2n, averageProcessingMs: 120_000n },
]);
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,
signatureImports: 0,
drainageInfos: 0,
templates: 1,
total: 5,
},
today: expect.objectContaining({
returnedCents: 10,
segmentCount: 2,
deliveredSegmentCount: 18,
arrivalRate: 90,
billedCents: 360,
profitCents: 144,
profitRate: 40,
}),
hourlySendTrend: expect.arrayContaining([
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
]),
auditProcessingSpeed: [
{ category: 'enterpriseCertifications', label: '企业认证', count: 0, averageProcessingMs: null },
{ category: 'smsAudits', label: '短信审核', count: 0, averageProcessingMs: null },
{ category: 'templates', label: '模板', count: 3, averageProcessingMs: 90000 },
{ category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 },
{ category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null },
],
enterpriseSpendRanks: [
{
tenantId: 'tenant-1',
tenantName: '租户A',
todaySpendCents: 24000,
balanceCents: 1000000,
creditCents: 50000,
},
],
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) },
},
});
const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment');
expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"');
expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"');
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string };
expect(hourlyTrendQuery.sql).toContain(
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
);
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 real daily channel and signature quality for the selected Shanghai date', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw
.mockResolvedValueOnce([
{
channelId: 'channel-1',
channelName: '通道一',
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
submitFailureRate: 20,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
unknownRate: 20,
failureRate: 20,
averageArrivalMs: 1200,
},
])
.mockResolvedValueOnce([
{
id: 'signature-1:plain',
signatureId: 'signature-1',
signatureName: '【测试签名】',
tenantId: 'tenant-1',
tenantName: '租户A',
hasDrainage: false,
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
averageArrivalMs: 1200,
},
])
.mockResolvedValueOnce([
{
total: 5,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
},
])
.mockResolvedValueOnce([
{
applicationId: 'app-1',
applicationName: '通知应用',
tenantId: 'tenant-1',
tenantName: '租户A',
total: 5,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
},
]);
const service = new OperationsService(prisma as never);
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
date: '2026-07-24',
drainageSignatures: [],
summary: {
total: 5,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
},
channels: [
expect.objectContaining({
channelId: 'channel-1',
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
submitFailureRate: 20,
successRate: 60,
}),
],
signatures: [
expect.objectContaining({
signatureId: 'signature-1',
signatureName: '【测试签名】',
hasDrainage: false,
acceptedCount: 4,
submitFailureCount: 1,
}),
],
applications: [
expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 }),
],
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
});
it('returns pending audit counts without running the full dashboard aggregation', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.pendingAudits('tenant-1')).resolves.toEqual({
enterpriseCertifications: 1,
smsAudits: 2,
templates: 1,
signatures: 1,
drainageInfos: 0,
signatureImports: 0,
total: 5,
});
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', auditStatus: 'pending' },
});
expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: 'pending' },
});
expect(prisma.smsSendTask.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: 'pending_review' },
});
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it('counts pending signature import rows within the requested tenant', async () => {
const prisma = createPrismaMock();
prisma.reportMaterialImportItem.count.mockResolvedValue(19);
const service = new OperationsService(prisma as never);
await expect(service.pendingAudits('tenant-1')).resolves.toMatchObject({ signatureImports: 19, total: 24 });
expect(prisma.reportMaterialImportItem.count).toHaveBeenCalledWith({
where: { reportType: 'signature', status: 'pending_review', batch: { tenantId: 'tenant-1' } },
});
});
it('rejects invalid send quality dates', async () => {
const service = new OperationsService(createPrismaMock() as never);
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
});
it('returns paged registered-signature quality with channel and carrier breakdowns', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw
.mockResolvedValueOnce([
{
signatureId: 'signature-1',
signatureName: '【测试签名】',
tenantId: 'tenant-1',
tenantName: '租户A',
applicationNames: '通知应用、营销应用',
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
successCount: 3,
unknownCount: 1,
failureCount: 0,
successRate: 75,
averageArrivalMs: 1200,
rowCount: 12,
},
])
.mockResolvedValueOnce([
{
signatureId: 'signature-1',
channelId: 'channel-1',
channelName: '通道一',
carrier: 'mobile',
drainageState: 'with',
total: 4,
acceptedCount: 4,
submitFailureCount: 0,
successCount: 3,
unknownCount: 1,
failureCount: 0,
successRate: 75,
averageArrivalMs: 1200,
},
{
signatureId: 'signature-1',
channelId: 'channel-2',
channelName: '通道二',
carrier: 'telecom',
drainageState: 'without',
total: 2,
acceptedCount: 1,
submitFailureCount: 1,
successCount: 1,
unknownCount: 0,
failureCount: 0,
successRate: 100,
averageArrivalMs: 1800,
},
]);
prisma.$queryRaw.mockResolvedValueOnce([
{
signatureId: 'signature-1',
carrier: 'mobile',
businessMessageCount: 3,
finalSuccessCount: 2,
finalSuccessRate: 66.7,
averageArrivalMs: 1100,
},
{
signatureId: 'signature-1',
carrier: 'telecom',
businessMessageCount: 2,
finalSuccessCount: 1,
finalSuccessRate: 50,
averageArrivalMs: 1800,
},
]);
const service = new OperationsService(prisma as never);
await expect(
service.signatureQuality({
date: '2026-07-24',
keyword: '测试',
page: 2,
pageSize: 5,
}),
).resolves.toEqual({
date: '2026-07-24',
items: [
expect.objectContaining({
signatureId: 'signature-1',
signatureName: '【测试签名】',
total: 5,
channelSubmitTotal: 6,
carrierOverview: [
expect.objectContaining({
carrier: 'mobile',
businessMessageCount: 3,
finalSuccessCount: 2,
finalSuccessRate: 66.7,
}),
expect.objectContaining({
carrier: 'telecom',
businessMessageCount: 2,
finalSuccessCount: 1,
finalSuccessRate: 50,
}),
],
breakdowns: [
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
],
drainageBreakdowns: [
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
],
}),
],
total: 12,
page: 2,
pageSize: 5,
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(3);
});
it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
date: '2026-07-24',
items: [],
total: 0,
page: 1,
pageSize: 25,
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
});
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('applies an explicit Beijing date interval to operation logs', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.systemLogs({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
createdAt: {
gte: new Date('2026-08-20T16:00:00.000Z'),
lte: new Date('2026-08-27T15:59:59.999Z'),
},
}),
}),
);
});
it('exports filtered operation logs with a traceable operation id', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(
expect.objectContaining({
operationId: expect.any(String),
status: 'completed',
recordCount: 1,
truncated: false,
content: expect.stringContaining('billing.manual_recharge'),
}),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 }));
});
it('derives client log export tenant from session user and removes internal detail and IP columns', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user');
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) }),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) }),
);
expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID');
expect(exported.content).not.toContain('amountCents');
});
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 receipt anomalies with status summary', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(
service.listReceiptAnomalies({
tenantId: 'tenant-1',
channelId: 'channel-1',
status: 'pending',
anomalyType: 'aggregate_success_then_failure',
keyword: 'MSG-1',
page: 1,
pageSize: 10,
}),
).resolves.toEqual(
expect.objectContaining({
total: 1,
page: 1,
pageSize: 10,
summary: {
pending: 1,
resolved: 0,
ignored: 0,
oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'),
},
}),
);
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
tenantId: 'tenant-1',
channelId: 'channel-1',
status: 'pending',
anomalyType: 'aggregate_success_then_failure',
}),
orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }],
take: 10,
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, code: true, name: true, status: true } },
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
submitRecord: { select: { submitId: true, submitStatus: true } },
receiptRecord: {
select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true },
},
},
}),
);
});
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,
attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] },
},
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',
updatedAtFrom: '2026-07-02',
updatedAtTo: '2026-07-08',
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 }],
},
});
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
updatedAt: {
gte: new Date('2026-07-01T16:00:00.000Z'),
lte: new Date('2026-07-08T15:59:59.999Z'),
},
}),
}),
);
});
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',
updatedAtFrom: '2026-07-02',
updatedAtTo: '2026-07-08',
}),
).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,
updatedAt: {
gte: new Date('2026-07-01T16:00:00.000Z'),
lte: new Date('2026-07-08T15:59:59.999Z'),
},
}),
}),
);
});
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: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }],
});
});
});