🤡 Unit testing scaffold

This commit is contained in:
2026-02-25 09:18:28 -05:00
parent 0af9482be9
commit e113066094
9 changed files with 26311 additions and 3001 deletions

1
__mocks__/fileMock.js Normal file
View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';

28
jest.config.js Normal file
View File

@@ -0,0 +1,28 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',
'**/?(*.)+(spec|test).+(ts|tsx|js)',
],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', {
tsconfig: {
jsx: 'react',
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
}],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
],
};

25
jest.setup.js Normal file
View File

@@ -0,0 +1,25 @@
require('@testing-library/jest-dom');
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;

22564
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
"dev": "rimraf dist && npm run build && vite",
"start": "npm run build && vite",
"docs": "jsdoc -c jsdoc.json",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@@ -92,6 +95,9 @@
"@storybook/testing-library": "^0.2.0",
"@tanstack/eslint-plugin-query": "^5.0.5",
"@tanstack/react-query-devtools": "^5.1.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@tsconfig/node14": "^1.0.0",
"@types/ellipsize": "^0.1.1",
"@types/express": "^4.17.8",
@@ -114,8 +120,10 @@
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-storybook": "^0.6.13",
"express": "^4.20.0",
"identity-obj-proxy": "^3.0.0",
"install": "^0.13.0",
"jest": "^29.6.3",
"jest-environment-jsdom": "^30.2.0",
"nodemon": "^3.0.1",
"postcss": "^8.4.32",
"postcss-import": "^15.1.0",
@@ -125,6 +133,7 @@
"sass": "^1.77.0",
"storybook": "^7.6.21",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.6",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6"
},

View File

@@ -0,0 +1,493 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { Import } from './Import';
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.MockedFunction<any>;
// Mock zustand store
const mockGetSocket = jest.fn();
const mockDisconnectSocket = jest.fn();
const mockSetStatus = jest.fn();
jest.mock('../../store', () => ({
useStore: jest.fn((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
),
}));
// Mock socket.io-client
const mockSocket = {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
};
mockGetSocket.mockReturnValue(mockSocket);
// Helper function to create a wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Import Component - Numerical Indices', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should display numerical indices in the Past Imports table', async () => {
// Mock API response with 3 import sessions
const mockData = [
{
sessionId: 'session-1',
earliestTimestamp: '2024-01-01T10:00:00Z',
completedJobs: 5,
failedJobs: 0
},
{
sessionId: 'session-2',
earliestTimestamp: '2024-01-02T10:00:00Z',
completedJobs: 3,
failedJobs: 1
},
{
sessionId: 'session-3',
earliestTimestamp: '2024-01-03T10:00:00Z',
completedJobs: 8,
failedJobs: 2
},
];
(axios as any).mockResolvedValue({ data: mockData });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for the "Past Imports" heading to appear
await waitFor(() => {
expect(screen.getByText('Past Imports')).toBeInTheDocument();
});
// Verify that the "#" column header exists
expect(screen.getByText('#')).toBeInTheDocument();
// Verify that numerical indices (1, 2, 3) are displayed in the first column of each row
const rows = screen.getAllByRole('row');
// Skip header row (index 0), check data rows
expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('1');
expect(rows[2].querySelectorAll('td')[0]).toHaveTextContent('2');
expect(rows[3].querySelectorAll('td')[0]).toHaveTextContent('3');
});
test('should display correct indices for larger datasets', async () => {
// Mock API response with 10 import sessions
const mockData = Array.from({ length: 10 }, (_, i) => ({
sessionId: `session-${i + 1}`,
earliestTimestamp: `2024-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
completedJobs: i + 1,
failedJobs: 0,
}));
(axios as any).mockResolvedValue({ data: mockData });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Past Imports')).toBeInTheDocument();
});
// Verify indices 1 through 10 are present in the first column
const rows = screen.getAllByRole('row');
// Skip header row (index 0)
for (let i = 1; i <= 10; i++) {
const row = rows[i];
const cells = row.querySelectorAll('td');
expect(cells[0]).toHaveTextContent(i.toString());
}
});
});
describe('Import Component - Button Visibility', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
test('should show Start Import button when queue status is drained', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Start Import')).toBeInTheDocument();
});
// Verify Pause and Resume buttons are NOT visible
expect(screen.queryByText('Pause')).not.toBeInTheDocument();
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
});
test('should show Start Import button when queue status is undefined', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: undefined,
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Start Import')).toBeInTheDocument();
});
});
test('should hide Start Import button and show Pause button when queue is running', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 5,
failedJobCount: 1,
mostRecentImport: 'Comic #123',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
expect(screen.getByText('Pause')).toBeInTheDocument();
});
// Verify Import Activity section is visible
expect(screen.getByText('Import Activity')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument(); // successful count
expect(screen.getByText('1')).toBeInTheDocument(); // failed count
});
test('should hide Start Import button and show Resume button when queue is paused', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'paused',
successfulJobCount: 3,
failedJobCount: 0,
mostRecentImport: 'Comic #456',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
expect(screen.getByText('Resume')).toBeInTheDocument();
});
});
});
describe('Import Component - SessionId and Socket Reconnection', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
localStorage.clear();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
jest.useRealTimers();
});
test('should clear sessionId and reconnect socket when starting import after queue is drained', async () => {
// Setup: Set old sessionId in localStorage
localStorage.setItem('sessionId', 'old-session-id');
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Click the "Start Import" button
const startButton = await screen.findByText('Start Import');
fireEvent.click(startButton);
// Verify sessionId is cleared immediately
expect(localStorage.getItem('sessionId')).toBeNull();
// Verify disconnectSocket is called
expect(mockDisconnectSocket).toHaveBeenCalledWith('/');
// Fast-forward 100ms
await act(async () => {
jest.advanceTimersByTime(100);
});
// Verify getSocket is called after 100ms
await waitFor(() => {
expect(mockGetSocket).toHaveBeenCalledWith('/');
});
// Fast-forward another 500ms
await act(async () => {
jest.advanceTimersByTime(500);
});
// Verify initiateImport is called and status is set to running
await waitFor(() => {
expect(axios.request).toHaveBeenCalledWith({
url: 'http://localhost:3000/api/library/newImport',
method: 'POST',
data: { sessionId: null },
});
expect(mockSetStatus).toHaveBeenCalledWith('running');
});
});
test('should NOT clear sessionId when starting import with undefined status', async () => {
// Setup: Set existing sessionId in localStorage
localStorage.setItem('sessionId', 'existing-session-id');
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: undefined,
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Click the "Start Import" button
const startButton = await screen.findByText('Start Import');
fireEvent.click(startButton);
// Verify sessionId is NOT cleared
expect(localStorage.getItem('sessionId')).toBe('existing-session-id');
// Verify disconnectSocket is NOT called
expect(mockDisconnectSocket).not.toHaveBeenCalled();
// Verify status is set to running immediately
expect(mockSetStatus).toHaveBeenCalledWith('running');
// Verify initiateImport is called immediately (no delay)
await waitFor(() => {
expect(axios.request).toHaveBeenCalledWith({
url: 'http://localhost:3000/api/library/newImport',
method: 'POST',
data: { sessionId: 'existing-session-id' },
});
});
});
});
describe('Import Component - Real-time Updates', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
test('should refetch table data when LS_COVER_EXTRACTED event is received', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for component to mount and socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
});
// Get the event handler that was registered
const coverExtractedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'LS_COVER_EXTRACTED'
)?.[1];
// Clear previous axios calls
(axios as any).mockClear();
// Simulate the socket event
if (coverExtractedHandler) {
coverExtractedHandler();
}
// Verify that the API is called again (refetch)
await waitFor(() => {
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
})
);
});
});
test('should refetch table data when LS_IMPORT_QUEUE_DRAINED event is received', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for component to mount and socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
});
// Get the event handler that was registered
const queueDrainedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'LS_IMPORT_QUEUE_DRAINED'
)?.[1];
// Clear previous axios calls
(axios as any).mockClear();
// Simulate the socket event
if (queueDrainedHandler) {
queueDrainedHandler();
}
// Verify that the API is called again (refetch)
await waitFor(() => {
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
})
);
});
});
test('should cleanup socket listeners on unmount', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
const { unmount } = render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Unmount the component
unmount();
// Verify that socket listeners are removed
expect(mockSocket.off).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
});
});
export {};

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useCallback, useEffect } from "react";
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns";
import Loader from "react-loader-spinner";
@@ -27,21 +27,24 @@ interface IProps {
export const Import = (props: IProps): ReactElement => {
const queryClient = useQueryClient();
const { importJobQueue, getSocket } = useStore(
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
})),
);
const sessionId = localStorage.getItem("sessionId");
const { mutate: initiateImport } = useMutation({
mutationFn: async () =>
await axios.request({
mutationFn: async () => {
const sessionId = localStorage.getItem("sessionId");
return await axios.request({
url: `http://localhost:3000/api/library/newImport`,
method: "POST",
data: { sessionId },
}),
});
},
});
const { data, isError, isLoading, refetch } = useQuery({
@@ -83,7 +86,7 @@ export const Import = (props: IProps): ReactElement => {
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
};
}, [getSocket, refetch]);
}, [getSocket, refetch, socketReconnectTrigger]);
const toggleQueue = (queueAction: string, queueStatus: string) => {
const socket = getSocket("/");
@@ -193,8 +196,26 @@ export const Import = (props: IProps): ReactElement => {
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
// Clear old sessionId when starting a new import after queue is drained
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
// Disconnect and reconnect socket to get new sessionId
disconnectSocket("/");
// Wait for socket to reconnect and get new sessionId before starting import
setTimeout(() => {
getSocket("/");
// Trigger useEffect to re-attach event listeners
setSocketReconnectTrigger(prev => prev + 1);
// Wait a bit more for sessionInitialized event to fire
setTimeout(() => {
initiateImport();
importJobQueue.setStatus("running");
}, 500);
}, 100);
} else {
initiateImport();
importJobQueue.setStatus("running");
}
}}
>
<span className="text-md">Start Import</span>

View File

@@ -50,12 +50,14 @@ export const useStore = create<StoreState>((set, get) => ({
console.log(`✅ Connected to ${namespace}:`, socket.id);
});
// Always listen for sessionInitialized in case backend creates a new session
socket.on("sessionInitialized", (id) => {
console.log("Session initialized with ID:", id);
localStorage.setItem("sessionId", id);
});
if (sessionId) {
socket.emit("call", "socket.resumeSession", { sessionId, namespace });
} else {
socket.on("sessionInitialized", (id) => {
localStorage.setItem("sessionId", id);
});
}
socket.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
@@ -90,14 +92,17 @@ export const useStore = create<StoreState>((set, get) => ({
});
socket.on("LS_IMPORT_QUEUE_DRAINED", () => {
localStorage.removeItem("sessionId");
set((state) => ({
importJobQueue: {
...state.importJobQueue,
status: "drained",
},
}));
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
// Delay query invalidation and sessionId removal to ensure backend has persisted data
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
localStorage.removeItem("sessionId");
}, 500);
});
socket.on("CV_SCRAPING_STATUS", (data) => {

6136
yarn.lock

File diff suppressed because it is too large Load Diff