634 lines
25 KiB
TypeScript
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' }),
|
|
});
|
|
});
|
|
});
|