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

1496 lines
62 KiB
TypeScript

import { ChannelsService } from './channels.service';
const mockJobRemove = jest.fn().mockResolvedValue(undefined);
const mockQueueAdd = jest.fn().mockImplementation(async () => ({ id: 'job-1', remove: mockJobRemove }));
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 mockRedisSet = jest.fn().mockResolvedValue('OK');
const mockRedisEval = jest.fn().mockResolvedValue(1);
const mockRedisDisconnect = jest.fn();
jest.mock('bullmq', () => ({
Queue: jest.fn().mockImplementation(() => ({
add: mockQueueAdd,
close: mockQueueClose,
})),
}));
jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
xadd: mockRedisXadd,
set: mockRedisSet,
eval: mockRedisEval,
disconnect: mockRedisDisconnect,
})));
function createPrismaMock() {
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', reportType: 'signature', status: 'pending' };
const channel = {
id: 'channel-1',
code: 'CMPP-A',
name: '主通道',
carrier: 'mobile',
carriers: ['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 {
$queryRaw: jest.fn().mockResolvedValue([]),
$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({ ...channel, ...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, items: [] }),
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(),
findMany: jest.fn().mockResolvedValue([]),
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: '执照文件' }),
findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
},
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),
findFirst: jest.fn().mockResolvedValue(null),
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' }),
findMany: jest.fn().mockResolvedValue([]),
},
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 })),
count: jest.fn().mockResolvedValue(0),
},
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('sorts all filtered channels by today submit count before pagination', async () => {
const prisma = createPrismaMock();
const candidates = [
{ id: 'channel-low', name: '乙通道' },
{ id: 'channel-high', name: '甲通道' },
{ id: 'channel-zero', name: '丙通道' },
];
const fullChannels = candidates.map((channel) => ({ ...channel, connectionStates: [] }));
prisma.smsChannel.findMany
.mockResolvedValueOnce(candidates)
.mockResolvedValueOnce([fullChannels[0], fullChannels[1]]);
prisma.$queryRaw.mockResolvedValue([
{ channelId: 'channel-low', total: 3 },
{ channelId: 'channel-high', total: 12 },
]);
const service = new ChannelsService(prisma as never);
const result = await service.listChannelsPage({ page: 1, pageSize: 2 });
expect(result.items.map((channel) => channel.id)).toEqual(['channel-high', 'channel-low']);
expect(result.total).toBe(3);
expect(prisma.smsChannel.findMany).toHaveBeenNthCalledWith(2, {
where: { id: { in: ['channel-high', 'channel-low'] } },
include: { connectionStates: true },
});
});
it('uses channel name and id as a stable tie breaker for zero-submit channels', async () => {
const prisma = createPrismaMock();
const candidates = [
{ id: 'channel-b', name: 'A通道' },
{ id: 'channel-a', name: 'A通道' },
{ id: 'channel-c', name: 'B通道' },
];
prisma.smsChannel.findMany
.mockResolvedValueOnce(candidates)
.mockResolvedValueOnce(candidates);
prisma.$queryRaw.mockResolvedValue([]);
const service = new ChannelsService(prisma as never);
const result = await service.listChannelsPage({ page: 1, pageSize: 10 });
expect(result.items.map((channel) => channel.id)).toEqual(['channel-a', 'channel-b', 'channel-c']);
});
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('replaces one report type while preserving legacy both fields for the opposite type', async () => {
const prisma = createPrismaMock();
const legacyBoth = { id: 'legacy-1', channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'both', code: 'license', name: '营业执照', exportName: '旧表头', fieldType: 'file', required: true, description: null, sortOrder: 10, columnWidth: 18, imageWidth: 120, imageHeight: 80, defaultValue: null, transform: null, status: 'active', createdAt: new Date(), updatedAt: new Date() };
const tx = {
channelReportField: {
findMany: jest.fn().mockResolvedValueOnce([legacyBoth]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'signature-field' }]),
deleteMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `created-${data.reportType}`, ...data })),
},
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await service.replaceReportFields('channel-1', 'signature', { fields: [{ drainageFieldId: 'library-1', exportName: '新签名表头', required: true }] });
expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1', reportType: { in: ['signature', 'both'] } } });
expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'drainage', exportName: '旧表头' }) });
expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'signature', exportName: '新签名表头' }) });
});
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,
signature: { auditStatus: { not: 'deleted' } },
},
include: {
signature: { include: { tenant: true, application: true } },
channel: true,
drainageInfo: true,
exportItems: {
include: { exportFile: true, batchItem: { include: { batch: true } } },
orderBy: { id: 'desc' },
take: 1,
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: { createdAt: 'desc' },
});
});
it('adds real today delivery statistics and the latest successful send time to report tasks', async () => {
const prisma = createPrismaMock();
const task = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' };
const taskChannel = { id: 'channel-1', code: 'CMPP-A', name: '主通道' };
prisma.channelSignatureReportTask.findMany.mockResolvedValue([
{ ...task, reportType: 'signature', drainageItemId: null, channel: taskChannel },
{ ...task, id: 'report-task-2', reportType: 'drainage', drainageItemId: 'drain-1', channel: taskChannel },
]);
prisma.$queryRaw.mockResolvedValue([
{
channelId: 'channel-1',
signatureId: 'sig-1',
drainageInfoId: null,
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
successCount: 2,
unknownCount: 1,
failureCount: 1,
lastSuccessfulSentAt: new Date('2026-07-27T01:00:00.000Z'),
},
{
channelId: 'channel-1',
signatureId: 'sig-1',
drainageInfoId: 'drain-1',
total: 3,
acceptedCount: 3,
submitFailureCount: 0,
successCount: 3,
unknownCount: 0,
failureCount: 0,
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
},
]);
const service = new ChannelsService(prisma as never);
const result = await service.listReportTasks(undefined, undefined, 'channel-1');
expect(result[0]).toEqual(expect.objectContaining({
deliveryStats: {
total: 8,
acceptedCount: 7,
submitFailureCount: 1,
submitFailureRate: 12.5,
successCount: 5,
successRate: 71.4,
unknownCount: 1,
unknownRate: 14.3,
failureCount: 1,
failureRate: 14.3,
},
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
}));
expect(result[1]).toEqual(expect.objectContaining({
deliveryStats: expect.objectContaining({
total: 3,
submitFailureCount: 0,
successCount: 3,
successRate: 100,
}),
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
}));
});
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', carriers: ['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', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }),
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['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', carrier: 'mobile', 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.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'task-1' },
data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }),
});
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
it('uses the enterprise-signature save time when creating an approved carrier task', 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: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn() },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
update: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })),
findMany: jest.fn().mockResolvedValue([
{ id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' } },
]),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([]) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await service.changeReportTaskStatuses({
items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }],
sourceEntry: 'enterprise_signature',
});
expect(tx.channelSignatureReportTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
signatureId: 'sig-1',
channelId: 'channel-1',
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
approvedAt: expect.any(Date),
}),
});
});
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', carrier: null } });
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();
mockJobRemove.mockClear();
mockFetch.mockClear();
mockRedisXadd.mockClear();
mockRedisSet.mockClear();
mockRedisEval.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('replays every active channel connection after Gateway restart', async () => {
const prisma = createPrismaMock();
const activeChannel = await prisma.smsChannel.findUnique();
prisma.smsChannel.findMany.mockResolvedValue([activeChannel]);
const service = new ChannelsService(prisma as never);
await (service as unknown as { reconnectActiveChannelsAfterGatewayRestart(): Promise<void> })
.reconnectActiveChannelsAfterGatewayRestart();
expect(prisma.smsChannel.findMany).toHaveBeenCalledWith({ where: { status: 'active' } });
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
channelId: 'channel-1',
reason: 'gateway_restarted',
channel: expect.objectContaining({ rateLimitPerSecond: 100 }),
}), expect.objectContaining({
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
removeOnComplete: 1000,
removeOnFail: 1000,
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
body: expect.stringContaining('"reason":"gateway_restarted"'),
}));
});
it('uses the direct Gateway control path when the Redis marker queue is unavailable', async () => {
const prisma = createPrismaMock();
mockQueueAdd.mockRejectedValueOnce(new Error('redis unavailable'));
const service = new ChannelsService(prisma as never);
await expect(service.createChannel({
code: 'CMPP-DIRECT',
name: '直连控制测试',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
status: 'active',
})).resolves.toEqual(expect.objectContaining({ id: 'channel-1' }));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
}));
});
it('reuses a supplier state created concurrently by another API instance', async () => {
const prisma = createPrismaMock();
prisma.cmppConnectionState.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'state-concurrent', applicationId: null });
prisma.cmppConnectionState.create.mockRejectedValueOnce({ code: 'P2002' });
const service = new ChannelsService(prisma as never);
await expect(service.createChannel({
code: 'CMPP-CONCURRENT',
name: '并发状态测试',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
status: 'active',
})).resolves.toEqual(expect.objectContaining({ id: 'channel-1' }));
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
where: { id: 'state-concurrent' },
data: expect.objectContaining({ status: 'connecting' }),
});
});
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, serviceId: 'SMS' }),
}),
});
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' }),
}), expect.objectContaining({
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
removeOnComplete: 1000,
removeOnFail: 1000,
}));
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('defaults new channels to port 7890, CMPP and SMS while rejecting protocol overrides', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createChannel({
code: 'CMPP-DEFAULT',
name: '默认通道',
gatewayHost: '127.0.0.1',
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
protocol: 'HTTP',
});
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
data: expect.objectContaining({
gatewayPort: 7890,
protocol: 'CMPP',
config: expect.objectContaining({ serviceId: 'SMS' }),
}),
});
});
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');
await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters');
await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow('longMessageReceiptMode must be per_segment or message_level');
});
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',
}),
});
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
}));
});
it('does not request a reconnect when only non-connection channel fields change', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.updateChannel('channel-1', {
name: '主通道-新名称',
carrier: 'unicom',
sendRegion: '上海',
rateLimitPerSecond: 200,
unitPrice: 5,
});
expect(prisma.smsChannel.update).toHaveBeenCalled();
expect(prisma.cmppConnectionState.create).not.toHaveBeenCalled();
expect(prisma.cmppConnectionState.update).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not request a reconnect when a full edit payload keeps connection settings unchanged', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.updateChannel('channel-1', {
name: '主通道-完整保存',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
cmppVersion: '2.0',
desiredConnections: 1,
windowSize: 16,
heartbeatIntervalSeconds: 30,
heartbeatMissThreshold: 3,
rateLimitPerSecond: 300,
config: { serviceId: 'SMS' },
});
expect(prisma.smsChannel.update).toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
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('persists a message-level long-message receipt mode without requesting a reconnect', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.updateChannel('channel-1', { config: { longMessageReceiptMode: 'message_level' } });
expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }),
}),
}));
expect(mockFetch).not.toHaveBeenCalled();
});
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: '山东' }),
});
expect(prisma.smsChannelGroupItem.create.mock.calls[0][0].data).not.toHaveProperty('rateLimitPerSecond');
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',
code: 'G-MOBILE',
name: '移动组更新',
carrier: 'mobile',
description: null,
status: 'active',
retryEnabled: true,
retryTimeLimitMinutes: 750,
items: [
{ channelId: 'channel-national', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { code: 'CMPP-N', name: '全国通道' } },
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10, weight: 1, isBackup: false, channel: { code: 'CMPP-SD', name: '山东通道' } },
],
}),
},
operationLog: { create: jest.fn() },
};
await transactionCallback(tx);
expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({
where: { id: 'group-1' },
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
});
for (const item of tx.smsChannelGroupItem.createMany.mock.calls[0][0].data) {
expect(item).not.toHaveProperty('rateLimitPerSecond');
}
expect(tx.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel_group.update',
resource: 'sms_channel_group',
resourceId: 'group-1',
detail: expect.objectContaining({
before: expect.objectContaining({ name: '移动组' }),
after: expect.objectContaining({
name: '移动组更新',
items: expect.arrayContaining([
expect.objectContaining({ channelId: 'channel-national', priority: 1, channelName: '全国通道' }),
]),
}),
}),
}),
});
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('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] });
prisma.channelRouteRule.findMany.mockResolvedValueOnce([
{ applicationId: 'app-active' },
{ applicationId: 'app-active' },
{ applicationId: 'app-deleted' },
{ applicationId: 'app-missing' },
]);
prisma.smsApplication.findMany.mockResolvedValueOnce([
{ id: 'app-active', status: 'active' },
{ id: 'app-deleted', status: 'deleted' },
]);
prisma.smsSubmitRecord.count.mockResolvedValueOnce(2);
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
groupId: 'group-1',
groupName: '移动主通道组',
normalApplicationCount: 1,
deletedApplicationCount: 2,
channelCount: 2,
pendingSupplierSubmitCount: 2,
});
expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({
where: { channelGroupId: 'group-1', submitStatus: 'queued' },
});
});
it('logically deletes channel groups without removing application bindings or group items', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' });
const operationLogCreate = jest.fn();
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]);
prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]);
prisma.$transaction.mockImplementationOnce((callback) => callback({
smsChannelGroup: { update: groupUpdate },
operationLog: { create: operationLogCreate },
}));
await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' });
expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } });
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled();
expect(operationLogCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel_group.delete',
detail: expect.objectContaining({
deletionMode: 'soft_delete',
impact: expect.objectContaining({ normalApplicationCount: 1 }),
}),
}),
});
});
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', carrier: 'mobile', 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: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }),
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: expect.objectContaining({ status: 'partial', reason: 'one rejected', approvedAt: null }),
});
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: expect.objectContaining({ status: 'partial', reason: 'carrier receipt', approvedAt: null }),
});
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',
}), expect.objectContaining({
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
removeOnComplete: 1000,
removeOnFail: 1000,
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"reason":"channel_enabled"'),
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"reason":"channel_disabled"'),
}));
});
it('reconnects active failed channels and disconnects inactive live channels during reconciliation', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findMany.mockResolvedValue([
{
...(await prisma.smsChannel.findUnique()),
id: 'channel-active',
status: 'active',
connectionStates: [{
id: 'state-active',
connectionId: 'channel-active:primary',
status: 'failed',
currentConnections: 0,
desiredConnections: 1,
lastHeartbeatAt: null,
nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'),
}],
},
{
...(await prisma.smsChannel.findUnique()),
id: 'channel-disabled',
status: 'disabled',
connectionStates: [{
id: 'state-disabled',
connectionId: 'channel-disabled:primary',
status: 'connected',
currentConnections: 1,
desiredConnections: 1,
lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'),
nextReconnectAt: null,
}],
},
]);
const service = new ChannelsService(prisma as never);
const result = await service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'));
expect(result).toEqual({ scanned: 2, reconnectRequested: 1, disconnectRequested: 1 });
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
body: expect.stringContaining('"reason":"automatic_reconnect"'),
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({
body: expect.stringContaining('"reason":"inactive_channel_reconcile"'),
}));
expect(mockRedisSet).toHaveBeenCalledTimes(2);
expect(mockRedisEval).toHaveBeenCalledTimes(2);
});
it('does not reconnect a fresh healthy supplier connection', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findMany.mockResolvedValue([{
...(await prisma.smsChannel.findUnique()),
status: 'active',
config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 },
connectionStates: [{
connectionId: 'channel-1:primary',
status: 'connected',
currentConnections: 1,
desiredConnections: 1,
lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'),
nextReconnectAt: null,
}],
}]);
const service = new ChannelsService(prisma as never);
await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')))
.resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 });
expect(mockFetch).not.toHaveBeenCalled();
expect(mockRedisSet).not.toHaveBeenCalled();
});
it('skips duplicate reconciliation when another API instance owns the Redis lease', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findMany.mockResolvedValue([{
...(await prisma.smsChannel.findUnique()),
status: 'active',
connectionStates: [],
}]);
mockRedisSet.mockResolvedValueOnce(null);
const service = new ChannelsService(prisma as never);
await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')))
.resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 });
expect(mockFetch).not.toHaveBeenCalled();
expect(mockRedisEval).not.toHaveBeenCalled();
});
it('stores supplier heartbeat as a connected state and heartbeat audit event', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.upsertConnectionState({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'heartbeat',
currentConnections: 1,
lastHeartbeatAt: '2026-07-23T11:00:00.000Z',
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'connected',
lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'),
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'cmpp_connection.heartbeat' }),
});
});
it('updates every supplier heartbeat without flooding operation logs', async () => {
const prisma = createPrismaMock();
prisma.cmppConnectionState.findFirst.mockResolvedValue({
id: 'state-1',
applicationId: null,
lastHeartbeatAt: new Date('2026-07-23T10:59:30.000Z'),
});
const service = new ChannelsService(prisma as never);
await service.upsertConnectionState({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'heartbeat',
currentConnections: 1,
lastHeartbeatAt: '2026-07-23T11:00:00.000Z',
});
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
where: { id: 'state-1' },
data: expect.objectContaining({
status: 'connected',
lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'),
}),
});
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
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' }),
});
});
});