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

912 lines
38 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(''),
});
const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0');
const mockRedisDisconnect = jest.fn();
jest.mock('bullmq', () => ({
Queue: jest.fn().mockImplementation(() => ({
add: mockQueueAdd,
close: mockQueueClose,
})),
}));
jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
xadd: mockRedisXadd,
disconnect: mockRedisDisconnect,
})));
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: '2.0',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
config: { serviceId: 'SMS' },
sendRegion: '山东',
connectionStates: [{ id: 'state-1', connectionId: 'conn-a', status: 'connected', currentConnections: 1 }],
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, retryTimeLimitMinutes: 4320 }),
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().mockResolvedValue([]),
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 })),
},
drainageField: {
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
},
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().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
create: jest.fn().mockResolvedValue(reportTask),
findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
},
smsDrainageInfo: {
findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }),
},
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: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null }),
update: jest.fn(),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
tenant: {
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
},
smsBatchTask: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'batch-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'batch-1', ...data })),
},
cmppSubmitSession: {
upsert: jest.fn().mockResolvedValue({ id: 'session-1', channelId: 'channel-1', sessionNo: 'OPEN-channel-1' }),
},
smsMessageRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'message-record-1', ...data })),
},
smsSubmitRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
},
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', () => {
it('creates channel report requirements only from the report field library', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createReportField({ channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'signature', code: 'ignored', name: 'ignored', fieldType: 'string' });
expect(prisma.channelReportField.create).toHaveBeenCalledWith({
data: expect.objectContaining({
channelId: 'channel-1',
drainageFieldId: 'library-1',
reportType: 'signature',
code: 'license',
name: '营业执照',
fieldType: 'file',
required: true,
}),
});
});
it('lists report tasks for one real channel', async () => {
const prisma = createPrismaMock();
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
const service = new ChannelsService(prisma as never);
await service.listReportTasks(undefined, undefined, 'channel-1');
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined },
include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' },
});
});
it('changes channel report status and recomputes the signature summary atomically', async () => {
const prisma = createPrismaMock();
const tx = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
]);
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock();
const tx = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }),
create: jest.fn(),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
reason: '引流信息已报备',
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.smsSignature.update).not.toHaveBeenCalled();
});
beforeEach(() => {
mockQueueAdd.mockClear();
mockQueueClose.mockClear();
mockFetch.mockClear();
mockRedisXadd.mockClear();
mockRedisDisconnect.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',
desiredConnections: 2,
windowSize: 32,
rateLimitPerSecond: 750,
config: { extensionDigits: 4 },
});
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
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: '2.0',
rateLimitPerSecond: 750,
sendRegion: '全国',
status: 'active',
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }),
}),
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
data: expect.objectContaining({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'connecting',
desiredConnections: 2,
currentConnections: 0,
}),
});
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
messageType: 'ConnectChannel',
channelId: 'channel-1',
connectionId: 'channel-1:primary',
reason: 'channel_created',
channel: expect.objectContaining({ cmppVersion: '2.0' }),
}), { 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: 13, retryTimeLimitMinutes: 750 }),
});
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('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createChannel({
code: 'CMPP-3',
name: '3.0通道',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
cmppVersion: '3.0',
});
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
data: expect.objectContaining({ cmppVersion: '3.0' }),
});
await expect(service.createChannel({
code: 'BAD',
name: '非法版本',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
cmppVersion: '1.0',
})).rejects.toThrow('cmppVersion must be 2.0 or 3.0');
});
it('rejects invalid channel rate limits and extension digit counts', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const channel = {
code: 'CMPP-CONFIG',
name: '配置校验通道',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
};
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20');
});
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',
desiredConnections: 3,
windowSize: 64,
rateLimitPerSecond: 320,
config: { extensionDigits: 2 },
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,
rateLimitPerSecond: 320,
config: expect.objectContaining({ desiredConnections: 3, windowSize: 64, extensionDigits: 2 }),
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: 'channel-1',
}),
});
});
it('persists an arbitrary integer extension digit count within the supported range', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.updateChannel('channel-1', { config: { extensionDigits: 15 } });
expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ config: expect.objectContaining({ extensionDigits: 15 }) }),
}));
});
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,
retryTimeLimitMinutes: 750,
items: [
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
],
});
expect(prisma.$transaction).toHaveBeenCalled();
const transactionCallback = prisma.$transaction.mock.calls[0][0];
const tx = {
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
smsChannelGroup: {
update: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }),
},
};
await transactionCallback(tx);
expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({
where: { id: 'group-1' },
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
});
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: 'reporting' },
});
});
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: 'reporting' },
});
});
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: '主通道副本', status: 'disabled' }));
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('queues channel test SMS through real message records and gateway stream', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const result = await service.testChannel('channel-1', {
phoneNumber: '18821203795',
content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。',
operatorId: 'admin-1',
});
expect(result).toEqual(expect.objectContaining({
channelId: 'channel-1',
status: 'submit_queued',
submitted: 1,
testNo: expect.stringMatching(/^CHTEST-/),
}));
expect(prisma.tenant.findFirst).not.toHaveBeenCalled();
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
phoneNumber: '18821203795',
channelId: 'channel-1',
status: 'submit_queued',
submitStatus: 'queued',
}),
});
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
channelId: 'channel-1',
sessionId: 'session-1',
submitStatus: 'queued',
}),
});
expect(mockQueueAdd).toHaveBeenCalledWith('submit-command', expect.objectContaining({
messageType: 'SubmitCommand',
channelId: 'channel-1',
phoneNumber: '18821203795',
content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。',
upstream: expect.objectContaining({ cmppVersion: '2.0', gatewayHost: '127.0.0.1' }),
}));
expect(mockRedisXadd).toHaveBeenCalledWith(
'gateway.submit.commands',
'*',
'messageType',
'SubmitCommand',
'data',
expect.stringContaining('"phoneNumber":"18821203795"'),
);
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel.test_submit',
resource: 'sms_channel',
resourceId: 'channel-1',
detail: expect.objectContaining({ testNo: expect.stringMatching(/^CHTEST-/) }),
}),
});
});
it('rejects channel test SMS when no CMPP connection is online', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findUnique.mockResolvedValueOnce({
...(await prisma.smsChannel.findUnique()),
connectionStates: [{ connectionId: 'conn-a', status: 'failed', currentConnections: 0 }],
});
const service = new ChannelsService(prisma as never);
await expect(service.testChannel('channel-1', {
phoneNumber: '18821203795',
content: '测试短信',
})).rejects.toThrow('通道当前没有可用 CMPP 连接');
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(mockRedisXadd).not.toHaveBeenCalled();
});
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', lastError: null }),
});
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
where: { channelId: 'channel-1' },
orderBy: { updatedAt: 'desc' },
});
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
});
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' }),
});
});
});