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

634 lines
25 KiB
TypeScript

import { ChannelsService } from './channels.service';
const mockQueueAdd = jest.fn().mockResolvedValue(undefined);
const mockQueueClose = jest.fn().mockResolvedValue(undefined);
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
text: jest.fn().mockResolvedValue(''),
});
jest.mock('bullmq', () => ({
Queue: jest.fn().mockImplementation(() => ({
add: mockQueueAdd,
close: mockQueueClose,
})),
}));
function createPrismaMock() {
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
const channel = {
id: 'channel-1',
code: 'CMPP-A',
name: '主通道',
carrier: 'mobile',
protocol: 'CMPP',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
enterpriseCode: 'EC',
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
config: { serviceId: 'SMS' },
sendRegion: '山东',
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
};
return {
$transaction: jest.fn((callback) => callback({
smsChannel: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
},
smsChannelGroup: {
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
},
smsChannelGroupItem: {
deleteMany: jest.fn(),
createMany: jest.fn(),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
},
operationLog: {
create: jest.fn(),
},
})),
smsChannel: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
},
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
},
smsChannelGroupItem: {
deleteMany: jest.fn(),
createMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
},
channelRouteRule: {
findMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
},
channelReportField: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
},
channelSignatureReportTask: {
findMany: jest.fn(),
create: jest.fn().mockResolvedValue(reportTask),
findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
},
channelSignatureReportRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'record-1', ...data })),
findMany: jest.fn(),
},
reportExportFile: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'export-1', ...data })),
},
reportReceiptImport: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })),
},
smsSignature: {
update: jest.fn(),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
cmppConnectionState: {
findMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
operationLog: {
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date() }]),
},
};
}
describe('ChannelsService', () => {
beforeEach(() => {
mockQueueAdd.mockClear();
mockQueueClose.mockClear();
mockFetch.mockClear();
global.fetch = mockFetch as never;
});
it('rejects incomplete channel creation input with readable 400 errors', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow('Missing required channel fields');
expect(prisma.smsChannel.create).not.toHaveBeenCalled();
});
it('creates CMPP channels and route rules with first-version defaults', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createChannel({
code: 'CMPP-A',
name: '主通道',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
});
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 });
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
data: expect.objectContaining({
protocol: 'CMPP',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
sendRegion: '全国',
status: 'active',
}),
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
data: expect.objectContaining({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'connecting',
desiredConnections: 1,
currentConnections: 0,
}),
});
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
messageType: 'ConnectChannel',
channelId: 'channel-1',
connectionId: 'channel-1:primary',
reason: 'channel_created',
}), { jobId: 'channel-1:primary:connect' });
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"messageType":"ConnectChannel"'),
}));
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
});
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
channelId: undefined,
carrier: 'mobile',
priority: 100,
status: 'active',
}),
});
});
it('updates CMPP channel configuration without requiring password changes', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.updateChannel('channel-1', {
name: '主通道-编辑',
gatewayHost: '10.0.0.1',
gatewayPort: 27890,
carrier: 'all',
sendRegion: '全国',
account: 'sp-new',
srcId: '10690001',
unitPrice: 4,
})).resolves.toEqual(expect.objectContaining({
id: 'channel-1',
name: '主通道-编辑',
gatewayHost: '10.0.0.1',
}));
expect(prisma.smsChannel.update).toHaveBeenCalledWith({
where: { id: 'channel-1' },
data: expect.objectContaining({
name: '主通道-编辑',
gatewayHost: '10.0.0.1',
gatewayPort: 27890,
carrier: 'all',
passwordCipher: undefined,
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: 'channel-1',
}),
});
});
it('rejects invalid channel update ports', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow('gatewayPort must be an integer between 1 and 65535');
expect(prisma.smsChannel.update).not.toHaveBeenCalled();
});
it('rejects direct single-channel route rules', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' }))
.rejects.toThrow('Route rules can only bind channel groups');
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('enforces single-carrier channel groups and compatible group items', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
expect(() => service.createGroup({ code: 'G-ALL', name: '三网组', carrier: 'all' })).toThrow('carrier must be mobile, unicom, or telecom');
await service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东', priority: 10 });
expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东' }),
});
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' }))
.rejects.toThrow('Channel group items must use the same carrier');
const compatibleChannel = {
id: 'channel-1',
code: 'CMPP-A',
name: '主通道',
carrier: 'mobile',
protocol: 'CMPP',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
enterpriseCode: 'EC',
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
config: { serviceId: 'SMS' },
sendRegion: '山东',
};
prisma.smsChannel.findUnique.mockResolvedValueOnce({ ...compatibleChannel, carrier: 'telecom' });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-x', carrier: 'mobile' }))
.rejects.toThrow('Channel carrier is not compatible');
prisma.smsChannel.findUnique.mockResolvedValue(compatibleChannel);
prisma.smsChannelGroupItem.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'province-item', province: '山东' });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-2', carrier: 'mobile', province: '山东' }))
.rejects.toThrow('同一通道组内同一省份只能配置一个通道');
});
it('rejects province routes with mismatched channel sendRegion and duplicate national priorities', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-henan', carrier: 'all', sendRegion: '河南' });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-henan', carrier: 'mobile', province: '山东' }))
.rejects.toThrow('Province route must use a channel with the same sendRegion');
prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-national', carrier: 'all', sendRegion: '全国' });
prisma.smsChannelGroupItem.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'national-priority-1', province: null, priority: 1 });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-national', carrier: 'mobile', priority: 1 }))
.rejects.toThrow('同一通道组内全国通道优先级不能重复');
});
it('updates channel groups and replaces items with backend validation', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannel.findMany.mockResolvedValue([
{ id: 'channel-sd', carrier: 'mobile', sendRegion: '山东' },
{ id: 'channel-national', carrier: 'all', sendRegion: '全国' },
]);
await service.updateGroup('group-1', {
name: '移动组更新',
carrier: 'mobile',
retryEnabled: true,
retryTimeLimitHours: 24,
items: [
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
],
});
expect(prisma.$transaction).toHaveBeenCalled();
await expect(service.updateGroup('group-1', {
carrier: 'mobile',
items: [
{ channelId: 'channel-sd', carrier: 'mobile', priority: 1 },
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
],
})).rejects.toThrow('同一通道组内全国通道优先级不能重复');
});
it('requires route rule carrier to match the channel group carrier', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'unicom' }))
.rejects.toThrow('Route rule carrier must match the channel group carrier');
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('deletes channel groups only when no active route rule is bound', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.deleteGroup('group-1');
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
});
it('upserts signature report material per channel field', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.upsertReportMaterial({
signatureId: 'sig-1',
channelId: 'channel-1',
fieldCode: 'license',
fieldValue: '营业执照',
fileObjectId: 'file-1',
});
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith({
where: { signatureId_channelId_fieldCode: { signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' } },
update: { fieldValue: '营业执照', fileObjectId: 'file-1' },
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' }),
});
});
it('records report task creation, export, receipt import, and signature status synchronization', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', createdById: 'user-1' });
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
await service.importReportReceipt('report-task-1', {
fileName: 'receipt.csv',
rowCount: 10,
successCount: 9,
failedCount: 1,
reason: 'one rejected',
});
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'create', statusAfter: 'pending' }),
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'exporting', reason: undefined },
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'partial', reason: 'one rejected' },
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
data: { reportStatus: 'partial' },
});
});
it('parses text receipt imports and derives report task status', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.importReportReceipt('report-task-1', {
fileObjectId: 'file-1',
fileName: 'receipt.csv',
fileContent: 'phone,status\n13800138000,success\n13900139000,failed\n13700137000,通过',
reason: 'carrier receipt',
});
expect(prisma.reportReceiptImport.create).toHaveBeenCalledWith({
data: expect.objectContaining({
fileObjectId: 'file-1',
fileName: 'receipt.csv',
rowCount: 3,
successCount: 2,
failedCount: 1,
result: expect.objectContaining({ hasHeader: true, rows: expect.any(Array) }),
}),
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'partial', reason: 'carrier receipt' },
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
data: { reportStatus: 'partial' },
});
});
it('updates channel status with operation logs', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.changeChannelStatus('channel-1', { status: 'disabled', operatorId: 'admin-1', reason: 'maintenance' });
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'disabled' } });
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 'admin-1',
action: 'sms_channel.disabled',
resource: 'sms_channel',
resourceId: 'channel-1',
}),
});
await service.changeChannelStatus('channel-1', { status: 'active', operatorId: 'admin-1', reason: 'resume' });
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
data: expect.objectContaining({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'connecting',
}),
});
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
messageType: 'ConnectChannel',
channelId: 'channel-1',
reason: 'channel_enabled',
}), { jobId: 'channel-1:primary:connect' });
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"reason":"channel_enabled"'),
}));
});
it('copies channels with report field configuration and report materials', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' });
expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本' }));
expect(prisma.$transaction).toHaveBeenCalled();
});
it('soft deletes channels through status change', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.deleteChannel('channel-1', { operatorId: 'admin-1', status: 'deleted' });
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } });
});
it('upserts and lists CMPP connection states', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.upsertConnectionState({
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
connectionId: 'conn-a',
status: 'online',
desiredConnections: 2,
currentConnections: 1,
});
await service.listChannelConnections('channel-1');
await service.listTenantConnections('tenant-1');
await service.listChannelConnectionLogs('channel-1');
expect(prisma.cmppConnectionState.findFirst).toHaveBeenCalledWith({
where: { applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a' },
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected' }),
});
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
where: { channelId: 'channel-1' },
orderBy: { updatedAt: 'desc' },
take: 100,
});
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
take: 100,
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'cmpp_connection.connected',
resource: 'cmpp_connection',
resourceId: 'channel-1:conn-a',
}),
});
expect(prisma.operationLog.findMany).toHaveBeenCalled();
});
it('marks stale connecting CMPP connections as failed with operation logs', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const now = new Date('2026-07-06T10:00:45.000Z');
prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{
id: 'conn-state-1',
tenantId: 'tenant-1',
applicationId: null,
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'connecting',
desiredConnections: 1,
currentConnections: 0,
updatedAt: new Date('2026-07-06T10:00:00.000Z'),
}]);
await expect(service.markTimedOutConnectingChannels(now)).resolves.toEqual({ checked: 1, failed: 1 });
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
where: {
status: 'connecting',
updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') },
},
select: expect.objectContaining({
id: true,
channelId: true,
connectionId: true,
updatedAt: true,
}),
take: 100,
});
expect(prisma.cmppConnectionState.updateMany).toHaveBeenCalledWith({
where: {
id: 'conn-state-1',
status: 'connecting',
updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') },
},
data: expect.objectContaining({
status: 'failed',
currentConnections: 0,
lastDisconnectedAt: now,
lastError: 'Gateway connection request timed out after 30 seconds',
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
action: 'cmpp_connection.failed',
resource: 'cmpp_connection',
resourceId: 'channel-1:channel-1:primary',
detail: expect.objectContaining({
reason: 'connect_timeout',
timeoutMs: 30000,
status: 'failed',
previousStatus: 'connecting',
}),
}),
});
});
it('does not write timeout logs when a connecting state is already changed by gateway callback', async () => {
const prisma = createPrismaMock();
prisma.cmppConnectionState.updateMany.mockResolvedValueOnce({ count: 0 });
prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{
id: 'conn-state-1',
tenantId: 'tenant-1',
applicationId: null,
channelId: 'channel-1',
connectionId: 'channel-1:primary',
desiredConnections: 1,
currentConnections: 0,
updatedAt: new Date('2026-07-06T10:00:00.000Z'),
}]);
const service = new ChannelsService(prisma as never);
await expect(service.markTimedOutConnectingChannels(new Date('2026-07-06T10:00:45.000Z'))).resolves.toEqual({ checked: 1, failed: 0 });
expect(prisma.operationLog.create).not.toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'cmpp_connection.failed' }),
});
});
});