fix: harden real backend workflows and channel connections
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
const mockQueueAdd = jest.fn().mockResolvedValue(undefined);
|
||||
const mockQueueClose = jest.fn().mockResolvedValue(undefined);
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: jest.fn().mockResolvedValue(''),
|
||||
});
|
||||
|
||||
jest.mock('bullmq', () => ({
|
||||
Queue: jest.fn().mockImplementation(() => ({
|
||||
add: mockQueueAdd,
|
||||
close: mockQueueClose,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createPrismaMock() {
|
||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
||||
const channel = {
|
||||
@@ -54,6 +69,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
deleteMany: jest.fn(),
|
||||
@@ -63,6 +79,7 @@ function createPrismaMock() {
|
||||
},
|
||||
channelRouteRule: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
|
||||
},
|
||||
channelReportField: {
|
||||
@@ -93,9 +110,15 @@ function createPrismaMock() {
|
||||
smsSignature: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'conn-1', ...create })),
|
||||
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(),
|
||||
@@ -105,11 +128,18 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('ChannelsService', () => {
|
||||
beforeEach(() => {
|
||||
mockQueueAdd.mockClear();
|
||||
mockQueueClose.mockClear();
|
||||
mockFetch.mockClear();
|
||||
global.fetch = mockFetch as never;
|
||||
});
|
||||
|
||||
it('rejects incomplete channel creation input with readable 400 errors', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
expect(() => service.createChannel({ name: '缺字段通道' } as never)).toThrow('Missing required channel fields');
|
||||
await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow('Missing required channel fields');
|
||||
expect(prisma.smsChannel.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -138,6 +168,25 @@ describe('ChannelsService', () => {
|
||||
status: 'active',
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
reason: 'channel_created',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"messageType":"ConnectChannel"'),
|
||||
}));
|
||||
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
|
||||
});
|
||||
@@ -309,6 +358,18 @@ describe('ChannelsService', () => {
|
||||
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes channel groups only when no active route rule is bound', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.deleteGroup('group-1');
|
||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
|
||||
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
|
||||
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
|
||||
});
|
||||
|
||||
it('upserts signature report material per channel field', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -405,6 +466,24 @@ describe('ChannelsService', () => {
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
|
||||
await service.changeChannelStatus('channel-1', { status: 'active', operatorId: 'admin-1', reason: 'resume' });
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
reason: 'channel_enabled',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"reason":"channel_enabled"'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('copies channels with report field configuration and report materials', async () => {
|
||||
@@ -432,6 +511,7 @@ describe('ChannelsService', () => {
|
||||
|
||||
await service.upsertConnectionState({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'conn-a',
|
||||
status: 'online',
|
||||
@@ -440,12 +520,13 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
await service.listChannelConnections('channel-1');
|
||||
await service.listTenantConnections('tenant-1');
|
||||
await service.listChannelLinkLogs('channel-1');
|
||||
await service.listChannelConnectionLogs('channel-1');
|
||||
|
||||
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
update: expect.objectContaining({ tenantId: 'tenant-1', status: 'online', desiredConnections: 2, currentConnections: 1 }),
|
||||
create: expect.objectContaining({ channelId: 'channel-1', connectionId: 'conn-a', status: 'online' }),
|
||||
expect(prisma.cmppConnectionState.findFirst).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a' },
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected' }),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: { channelId: 'channel-1' },
|
||||
@@ -467,4 +548,86 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
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' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user