1954 lines
68 KiB
TypeScript
1954 lines
68 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('separates three-network totals without assigning unknown carrier traffic', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.channelSignatureReportTask.findMany.mockResolvedValue(
|
|
['mobile', 'unicom', 'telecom'].map((carrier) => ({
|
|
id: carrier,
|
|
channelId: 'c',
|
|
signatureId: 's',
|
|
reportType: 'signature',
|
|
carrier,
|
|
})),
|
|
);
|
|
prisma.$queryRaw.mockResolvedValue(
|
|
['mobile', 'unicom', 'telecom', null].map((carrier, i) => ({
|
|
carrier,
|
|
channelId: 'c',
|
|
signatureId: 's',
|
|
drainageInfoId: null,
|
|
total: i + 1,
|
|
acceptedCount: i + 1,
|
|
successCount: i + 1,
|
|
failureCount: 0,
|
|
unknownCount: 0,
|
|
submitFailureCount: 0,
|
|
lastSuccessfulSentAt: null,
|
|
})),
|
|
);
|
|
const rows = await new ChannelsService(prisma as never).listReportTasks();
|
|
expect(rows).toEqual(
|
|
[1, 2, 3].map((total) => expect.objectContaining({ deliveryStats: expect.objectContaining({ total }) })),
|
|
);
|
|
expect(prisma.$queryRaw.mock.calls[0][0].sql).toContain(
|
|
'GROUP BY channel_id, signature_id, drainage_info_id, carrier',
|
|
);
|
|
});
|
|
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 = {
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
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.each(['enterprise_signature', 'report_task'] as const)(
|
|
'changes channel report status from %s and recomputes the signature summary atomically',
|
|
async (sourceEntry) => {
|
|
const prisma = createPrismaMock();
|
|
const tx = {
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
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,
|
|
}),
|
|
).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,
|
|
}),
|
|
});
|
|
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 = {
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
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 = {
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
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',
|
|
carrier: null,
|
|
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 = {
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
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' }),
|
|
});
|
|
});
|
|
});
|