🔧 migrate to @lilith namespace, remove gitlab-ci.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lilith 2025-12-31 01:35:24 -08:00
parent 62ff55a1ca
commit 285de6673a
5 changed files with 2526 additions and 1 deletions

View file

@ -0,0 +1,181 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Job } from 'bullmq';
import { DlqController } from './dlq.controller';
import { QueueAdminService } from '../services/queue-admin.service';
// Mock QueueAdminService
const createMockAdminService = () => {
return {
listFailedJobs: vi.fn(),
retryFailedJob: vi.fn(),
removeFailedJob: vi.fn(),
} as unknown as QueueAdminService;
};
describe('DlqController', () => {
let controller: DlqController;
let adminService: ReturnType<typeof createMockAdminService>;
beforeEach(() => {
adminService = createMockAdminService();
controller = new DlqController(adminService);
});
describe('listFailedJobs', () => {
it('should return paginated failed jobs with default pagination', async () => {
const mockJobs = [
{
id: '1',
name: 'failed-job-1',
data: { test: 'data1' },
failedReason: 'Error occurred',
},
{
id: '2',
name: 'failed-job-2',
data: { test: 'data2' },
failedReason: 'Another error',
},
] as Job[];
adminService.listFailedJobs.mockResolvedValue(mockJobs);
const result = await controller.listFailedJobs('test-queue', {});
expect(result).toEqual({
jobs: mockJobs,
pagination: {
page: 1,
limit: 50,
},
});
expect(adminService.listFailedJobs).toHaveBeenCalledWith('test-queue', 0, 50);
});
it('should return paginated failed jobs with custom page and limit', async () => {
const mockJobs = [
{
id: '11',
name: 'failed-job-11',
data: { test: 'data11' },
failedReason: 'Error on page 2',
},
] as Job[];
adminService.listFailedJobs.mockResolvedValue(mockJobs);
const result = await controller.listFailedJobs('test-queue', {
page: 2,
limit: 10,
});
expect(result).toEqual({
jobs: mockJobs,
pagination: {
page: 2,
limit: 10,
},
});
expect(adminService.listFailedJobs).toHaveBeenCalledWith('test-queue', 10, 10);
});
it('should calculate start offset correctly for page 3', async () => {
const mockJobs = [] as Job[];
adminService.listFailedJobs.mockResolvedValue(mockJobs);
await controller.listFailedJobs('test-queue', {
page: 3,
limit: 25,
});
expect(adminService.listFailedJobs).toHaveBeenCalledWith('test-queue', 50, 25);
});
it('should handle empty failed jobs list', async () => {
adminService.listFailedJobs.mockResolvedValue([]);
const result = await controller.listFailedJobs('test-queue', {});
expect(result).toEqual({
jobs: [],
pagination: {
page: 1,
limit: 50,
},
});
});
it('should use page 1 when page is undefined', async () => {
const mockJobs = [{ id: '1' }] as Job[];
adminService.listFailedJobs.mockResolvedValue(mockJobs);
await controller.listFailedJobs('test-queue', { limit: 20 });
expect(adminService.listFailedJobs).toHaveBeenCalledWith('test-queue', 0, 20);
});
it('should use limit 50 when limit is undefined', async () => {
const mockJobs = [{ id: '1' }] as Job[];
adminService.listFailedJobs.mockResolvedValue(mockJobs);
await controller.listFailedJobs('test-queue', { page: 1 });
expect(adminService.listFailedJobs).toHaveBeenCalledWith('test-queue', 0, 50);
});
});
describe('retryFailedJob', () => {
it('should call adminService.retryFailedJob with correct parameters', async () => {
adminService.retryFailedJob.mockResolvedValue(undefined);
await controller.retryFailedJob('test-queue', '123');
expect(adminService.retryFailedJob).toHaveBeenCalledWith('test-queue', '123');
expect(adminService.retryFailedJob).toHaveBeenCalledOnce();
});
it('should handle retrying jobs with numeric job IDs as strings', async () => {
adminService.retryFailedJob.mockResolvedValue(undefined);
await controller.retryFailedJob('my-queue', '456');
expect(adminService.retryFailedJob).toHaveBeenCalledWith('my-queue', '456');
});
it('should return void on successful retry', async () => {
adminService.retryFailedJob.mockResolvedValue(undefined);
const result = await controller.retryFailedJob('test-queue', '789');
expect(result).toBeUndefined();
});
});
describe('removeFailedJob', () => {
it('should call adminService.removeFailedJob with correct parameters', async () => {
adminService.removeFailedJob.mockResolvedValue(undefined);
await controller.removeFailedJob('test-queue', '123');
expect(adminService.removeFailedJob).toHaveBeenCalledWith('test-queue', '123');
expect(adminService.removeFailedJob).toHaveBeenCalledOnce();
});
it('should handle removing jobs with numeric job IDs as strings', async () => {
adminService.removeFailedJob.mockResolvedValue(undefined);
await controller.removeFailedJob('my-queue', '456');
expect(adminService.removeFailedJob).toHaveBeenCalledWith('my-queue', '456');
});
it('should return void on successful removal', async () => {
adminService.removeFailedJob.mockResolvedValue(undefined);
const result = await controller.removeFailedJob('test-queue', '789');
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,680 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useJobs } from './useJobs';
import type { ReactNode } from 'react';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
// Create wrapper for react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useJobs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllTimers();
});
describe('successful data fetching', () => {
it('should fetch jobs successfully with default options', async () => {
const mockJobs = [
{
id: '1',
name: 'test-job-1',
state: 'completed',
data: { test: 'data' },
timestamp: Date.now(),
},
{
id: '2',
name: 'test-job-2',
state: 'failed',
data: { test: 'data' },
timestamp: Date.now(),
},
];
const mockResponse = {
data: mockJobs,
pagination: {
total: 2,
page: 1,
limit: 20,
totalPages: 1,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
}),
{ wrapper: createWrapper() }
);
// Initially loading
expect(result.current.isLoading).toBe(true);
expect(result.current.jobs).toEqual([]);
// Wait for data to load
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.jobs).toEqual(mockJobs);
expect(result.current.total).toBe(2);
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(20);
expect(result.current.totalPages).toBe(1);
expect(result.current.error).toBeNull();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs'
);
});
it('should build correct query string with all filter types', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 2,
limit: 10,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
state: 'failed',
name: 'test-job',
page: 2,
limit: 10,
sortBy: 'timestamp',
sortOrder: 'desc',
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs?state=failed&name=test-job&page=2&limit=10&sortBy=timestamp&sortOrder=desc'
);
});
it('should return correct pagination info', async () => {
const mockJobs = Array.from({ length: 10 }, (_, i) => ({
id: String(i + 1),
name: `job-${i + 1}`,
state: 'completed',
data: {},
timestamp: Date.now(),
}));
const mockResponse = {
data: mockJobs,
pagination: {
total: 100,
page: 3,
limit: 10,
totalPages: 10,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
page: 3,
limit: 10,
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.jobs).toHaveLength(10);
expect(result.current.total).toBe(100);
expect(result.current.page).toBe(3);
expect(result.current.limit).toBe(10);
expect(result.current.totalPages).toBe(10);
});
it('should handle enabled=false option', async () => {
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
enabled: false,
}),
{ wrapper: createWrapper() }
);
// Should not make any requests
expect(mockFetch).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.jobs).toEqual([]);
expect(result.current.total).toBe(0);
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(20);
expect(result.current.totalPages).toBe(0);
});
it('should refetch data when refetch is called', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Clear previous calls
mockFetch.mockClear();
// Trigger refetch
await result.current.refetch();
// Should make new request
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
});
describe('error handling', () => {
it('should handle fetch errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: 'Internal Server Error',
});
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeTruthy();
expect(result.current.error?.message).toContain('Failed to fetch jobs');
expect(result.current.jobs).toEqual([]);
expect(result.current.total).toBe(0);
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeTruthy();
expect(result.current.error?.message).toBe('Network error');
expect(result.current.jobs).toEqual([]);
});
});
describe('URL encoding', () => {
it('should encode queue name in URL properly', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue with spaces',
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue%20with%20spaces/jobs'
);
});
it('should encode special characters in queue name', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'queue/with/slashes',
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/queue%2Fwith%2Fslashes/jobs'
);
});
});
describe('staleTime configuration', () => {
it('should set correct staleTime', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockFetch).toHaveBeenCalledTimes(1);
// Clear mocks to test staleTime behavior
mockFetch.mockClear();
// Rerender the same hook (simulating component rerender)
// This should NOT trigger a new fetch since we're within staleTime window
result.current; // Access current to ensure no refetch on same instance
// The staleTime prevents immediate refetch - no new calls expected
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('query string building', () => {
it('should build query string with only state filter', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
state: 'waiting',
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs?state=waiting'
);
});
it('should build query string with only name filter', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
name: 'my-job',
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs?name=my-job'
);
});
it('should build query string with pagination filters', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 5,
limit: 50,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
page: 5,
limit: 50,
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs?page=5&limit=50'
);
});
it('should build query string with sort filters', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
sortBy: 'timestamp',
sortOrder: 'asc',
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs?sortBy=timestamp&sortOrder=asc'
);
});
it('should not include undefined filter values in query string', async () => {
const mockResponse = {
data: [],
pagination: {
total: 0,
page: 1,
limit: 20,
totalPages: 0,
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
state: 'waiting',
name: undefined,
page: undefined,
limit: undefined,
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues/test-queue/jobs?state=waiting'
);
});
});
describe('default values', () => {
it('should return default pagination values when data is not available', async () => {
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
enabled: false,
}),
{ wrapper: createWrapper() }
);
expect(result.current.jobs).toEqual([]);
expect(result.current.total).toBe(0);
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(20);
expect(result.current.totalPages).toBe(0);
});
it('should use filter values as fallback for pagination', async () => {
const mockResponse = {
data: [],
pagination: undefined, // Simulating missing pagination
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: 'test-queue',
filters: {
page: 3,
limit: 50,
},
}),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should fallback to filter values
expect(result.current.page).toBe(3);
expect(result.current.limit).toBe(50);
expect(result.current.total).toBe(0);
expect(result.current.totalPages).toBe(0);
});
});
describe('empty queue name handling', () => {
it('should not fetch when queue name is empty', async () => {
const { result } = renderHook(
() =>
useJobs({
apiUrl: 'http://localhost:3000',
queueName: '',
}),
{ wrapper: createWrapper() }
);
// Should not make any requests
expect(mockFetch).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.jobs).toEqual([]);
});
});
});

View file

@ -0,0 +1,626 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useQueueWebSocket } from './useQueueWebSocket';
import type { QueueMetrics } from '../types';
// Mock socket.io-client
const mockSocket = {
on: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connected: false,
};
const mockIo = vi.fn(() => mockSocket);
vi.mock('socket.io-client', () => ({
io: mockIo,
}));
describe('useQueueWebSocket', () => {
const defaultOptions = {
url: 'http://localhost:3000',
queues: ['email-queue', 'image-processing'],
token: 'test-token',
};
const createMockMetrics = (name: string): QueueMetrics => ({
name,
counts: {
waiting: 5,
active: 2,
completed: 100,
failed: 3,
delayed: 1,
paused: 0,
},
isPaused: false,
avgProcessingTime: 1500,
errorRate: 0.03,
throughput: {
completedLastHour: 50,
failedLastHour: 2,
},
oldestJob: {
id: 'job-123',
state: 'waiting',
age: 3600000,
},
timestamp: Date.now(),
});
beforeEach(() => {
vi.clearAllMocks();
mockSocket.connected = false;
mockSocket.on.mockReturnValue(mockSocket);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should initialize with correct default state', () => {
const { result } = renderHook(() => useQueueWebSocket({ ...defaultOptions, autoConnect: false }));
expect(result.current.isConnected).toBe(false);
expect(result.current.metrics.size).toBe(0);
expect(result.current.error).toBeNull();
expect(typeof result.current.subscribe).toBe('function');
expect(typeof result.current.unsubscribe).toBe('function');
expect(typeof result.current.disconnect).toBe('function');
});
it('should connect to WebSocket server with correct configuration', async () => {
renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockIo).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues',
expect.objectContaining({
auth: { token: 'test-token' },
transports: ['websocket', 'polling'],
})
);
});
});
it('should set isConnected to true on connect event', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function));
});
// Simulate connect event
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
expect(connectHandler).toBeDefined();
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
expect(result.current.error).toBeNull();
});
});
it('should subscribe to initial queues on connect', async () => {
renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function));
});
// Simulate connect event
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe', {
queues: ['email-queue', 'image-processing'],
});
});
});
it('should handle metrics event and update Map', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('metrics', expect.any(Function));
});
// Simulate metrics event
const metricsHandler = mockSocket.on.mock.calls.find(call => call[0] === 'metrics')?.[1];
expect(metricsHandler).toBeDefined();
const mockMetrics1 = createMockMetrics('email-queue');
const mockMetrics2 = createMockMetrics('image-processing');
act(() => {
metricsHandler?.(mockMetrics1);
});
await waitFor(() => {
expect(result.current.metrics.size).toBe(1);
expect(result.current.metrics.get('email-queue')).toEqual(mockMetrics1);
});
act(() => {
metricsHandler?.(mockMetrics2);
});
await waitFor(() => {
expect(result.current.metrics.size).toBe(2);
expect(result.current.metrics.get('email-queue')).toEqual(mockMetrics1);
expect(result.current.metrics.get('image-processing')).toEqual(mockMetrics2);
});
});
it('should handle disconnect event', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function));
});
// First connect
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Then disconnect
const disconnectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'disconnect')?.[1];
act(() => {
mockSocket.connected = false;
disconnectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(false);
});
});
it('should handle connect_error event', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('connect_error', expect.any(Function));
});
const connectErrorHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect_error')?.[1];
expect(connectErrorHandler).toBeDefined();
const testError = new Error('Connection failed');
act(() => {
connectErrorHandler?.(testError);
});
await waitFor(() => {
expect(result.current.error).toEqual(testError);
expect(result.current.isConnected).toBe(false);
});
});
it('should subscribe to a queue using subscribe function', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
vi.clearAllMocks(); // Clear the initial subscribe call
act(() => {
result.current.subscribe('new-queue');
});
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe', {
queues: ['new-queue'],
});
});
it('should not subscribe if socket is not connected', async () => {
const { result } = renderHook(() => useQueueWebSocket({ ...defaultOptions, autoConnect: false }));
act(() => {
result.current.subscribe('new-queue');
});
expect(mockSocket.emit).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith('Cannot subscribe: socket not connected');
});
it('should not subscribe to the same queue twice', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
vi.clearAllMocks();
// Try to subscribe twice
act(() => {
result.current.subscribe('new-queue');
result.current.subscribe('new-queue');
});
// Should only emit once
expect(mockSocket.emit).toHaveBeenCalledTimes(1);
});
it('should unsubscribe from a queue and remove metrics', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Add metrics for the queue
const metricsHandler = mockSocket.on.mock.calls.find(call => call[0] === 'metrics')?.[1];
const mockMetrics = createMockMetrics('email-queue');
act(() => {
metricsHandler?.(mockMetrics);
});
await waitFor(() => {
expect(result.current.metrics.has('email-queue')).toBe(true);
});
vi.clearAllMocks();
// Unsubscribe
act(() => {
result.current.unsubscribe('email-queue');
});
expect(mockSocket.emit).toHaveBeenCalledWith('unsubscribe', {
queues: ['email-queue'],
});
await waitFor(() => {
expect(result.current.metrics.has('email-queue')).toBe(false);
});
});
it('should not unsubscribe if not subscribed', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
vi.clearAllMocks();
// Try to unsubscribe from queue we never subscribed to
act(() => {
result.current.unsubscribe('non-existent-queue');
});
// Should not emit
expect(mockSocket.emit).not.toHaveBeenCalled();
});
it('should disconnect socket and clear state', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Disconnect
act(() => {
result.current.disconnect();
});
expect(mockSocket.disconnect).toHaveBeenCalled();
expect(result.current.isConnected).toBe(false);
});
it('should not connect when autoConnect is false', async () => {
renderHook(() => useQueueWebSocket({ ...defaultOptions, autoConnect: false }));
// Wait a bit to ensure no connection is attempted
await new Promise(resolve => setTimeout(resolve, 100));
expect(mockIo).not.toHaveBeenCalled();
});
it('should handle dynamic queue changes - subscribe to new queues', async () => {
const { result, rerender } = renderHook(
({ queues }) => useQueueWebSocket({ ...defaultOptions, queues }),
{ initialProps: { queues: ['email-queue'] } }
);
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
vi.clearAllMocks();
// Add new queue
rerender({ queues: ['email-queue', 'image-processing'] });
await waitFor(() => {
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe', {
queues: ['image-processing'],
});
});
});
it('should handle dynamic queue changes - unsubscribe from removed queues', async () => {
const { result, rerender } = renderHook(
({ queues }) => useQueueWebSocket({ ...defaultOptions, queues }),
{ initialProps: { queues: ['email-queue', 'image-processing'] } }
);
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate connected state
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Add metrics for both queues
const metricsHandler = mockSocket.on.mock.calls.find(call => call[0] === 'metrics')?.[1];
act(() => {
metricsHandler?.(createMockMetrics('email-queue'));
metricsHandler?.(createMockMetrics('image-processing'));
});
await waitFor(() => {
expect(result.current.metrics.size).toBe(2);
});
vi.clearAllMocks();
// Remove image-processing queue
rerender({ queues: ['email-queue'] });
await waitFor(() => {
expect(mockSocket.emit).toHaveBeenCalledWith('unsubscribe', {
queues: ['image-processing'],
});
expect(result.current.metrics.has('image-processing')).toBe(false);
expect(result.current.metrics.has('email-queue')).toBe(true);
});
});
it('should not handle queue changes if socket is not connected', async () => {
const { rerender } = renderHook(
({ queues }) => useQueueWebSocket({ ...defaultOptions, queues, autoConnect: false }),
{ initialProps: { queues: ['email-queue'] } }
);
// Change queues without connecting
rerender({ queues: ['email-queue', 'image-processing'] });
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
// Should not attempt to subscribe
expect(mockSocket.emit).not.toHaveBeenCalled();
});
it('should cleanup socket on unmount', async () => {
const { unmount } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
unmount();
expect(mockSocket.disconnect).toHaveBeenCalled();
});
it('should resubscribe to queues after reconnection', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Simulate initial connect
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1];
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Verify initial subscription
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe', {
queues: ['email-queue', 'image-processing'],
});
// Simulate disconnect
const disconnectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'disconnect')?.[1];
vi.clearAllMocks();
act(() => {
mockSocket.connected = false;
disconnectHandler?.();
});
// Wait for disconnect to be reflected
await waitFor(() => {
expect(result.current.isConnected).toBe(false);
}, { timeout: 2000 });
// Simulate reconnect
act(() => {
mockSocket.connected = true;
connectHandler?.();
});
// Wait for reconnect to be reflected
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
}, { timeout: 2000 });
// Verify resubscription
await waitFor(() => {
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe', {
queues: ['email-queue', 'image-processing'],
});
});
});
it('should handle metrics updates for the same queue', async () => {
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
const metricsHandler = mockSocket.on.mock.calls.find(call => call[0] === 'metrics')?.[1];
const metrics1 = createMockMetrics('email-queue');
const metrics2 = { ...createMockMetrics('email-queue'), counts: { ...metrics1.counts, waiting: 10 } };
act(() => {
metricsHandler?.(metrics1);
});
await waitFor(() => {
expect(result.current.metrics.get('email-queue')?.counts.waiting).toBe(5);
});
act(() => {
metricsHandler?.(metrics2);
});
await waitFor(() => {
expect(result.current.metrics.get('email-queue')?.counts.waiting).toBe(10);
});
});
it('should handle missing token in connection options', async () => {
const optionsWithoutToken = {
url: 'http://localhost:3000',
queues: ['email-queue'],
};
renderHook(() => useQueueWebSocket(optionsWithoutToken));
await waitFor(() => {
expect(mockIo).toHaveBeenCalledWith(
'http://localhost:3000/admin/queues',
expect.objectContaining({
auth: undefined,
transports: ['websocket', 'polling'],
})
);
});
});
it('should handle error when socket.io-client fails to load', async () => {
// Mock import failure
vi.doMock('socket.io-client', () => {
throw new Error('Module not found');
});
const { result } = renderHook(() => useQueueWebSocket(defaultOptions));
// The hook should handle the error gracefully
// Note: In real implementation, the error would be set via the catch block
// but since we're testing with vi.mock, the dynamic import succeeds
// This test documents the expected behavior if import fails
expect(result.current.isConnected).toBe(false);
expect(result.current.metrics.size).toBe(0);
});
});

File diff suppressed because it is too large Load diff

View file

@ -90,7 +90,8 @@ export class ReportingModule {
return {
module: ReportingModule,
imports: [
TypeOrmModule.forFeature([JobEvent], options.connection),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TypeOrmModule.forFeature([JobEvent], options.connection as any),
],
providers: [JobReporterService, JobAnalyticsService],
exports: [JobReporterService, JobAnalyticsService],