Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
8a4965c45b ci: Bump version to 0.2.6 [skip ci] 2025-12-28 00:01:33 +05:00
93497bf7c7 unit test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m2s
2025-12-27 11:00:19 -08:00
9 changed files with 130 additions and 98 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.2.5",
"version": "0.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.2.5",
"version": "0.2.6",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.2.5",
"version": "0.2.6",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -202,7 +202,7 @@ describe('FlyerUploader', () => {
expect(
screen.getByText('Processing complete! Redirecting to flyer 42...'),
).toBeInTheDocument();
});
}, { timeout: 4000 });
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
} catch (error) {
console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');

View File

@@ -53,15 +53,9 @@ export const useFlyerUploader = () => {
return 3000;
},
refetchOnWindowFocus: false, // No need to refetch on focus, interval is enough
retry: (failureCount, error: any) => {
// Don't retry for our custom JobFailedError, as it's a terminal state.
// Check for property instead of `instanceof` which can fail with vi.mock
if (error?.name === 'JobFailedError') {
return false;
}
// For other errors (like network issues), retry up to 3 times.
return failureCount < 3;
},
// If a poll fails (e.g., network error), don't retry automatically.
// The user can see the error and choose to retry manually if we build that feature.
retry: false,
});
const upload = useCallback(

View File

@@ -0,0 +1,74 @@
// src/middleware/multer.middleware.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
const mocks = vi.hoisted(() => ({
mkdir: vi.fn(),
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
// 2. Mock node:fs/promises.
// We mock the default export because that's how it's imported in the source file.
vi.mock('node:fs/promises', () => ({
default: {
mkdir: mocks.mkdir,
},
}));
// 3. Mock the logger service.
vi.mock('../services/logger.server', () => ({
logger: mocks.logger,
}));
// 4. Mock multer to prevent it from doing anything during import.
vi.mock('multer', () => ({
default: vi.fn(() => ({
single: vi.fn(),
array: vi.fn(),
})),
diskStorage: vi.fn(),
}));
describe('Multer Middleware Directory Creation', () => {
beforeEach(() => {
// Critical: Reset modules to ensure the top-level IIFE runs again for each test.
vi.resetModules();
vi.clearAllMocks();
});
it('should attempt to create directories on module load and log success', async () => {
// Arrange
mocks.mkdir.mockResolvedValue(undefined);
// Act: Dynamic import triggers the top-level code execution
await import('./multer.middleware');
// Assert
// It should try to create both the flyer storage and avatar storage paths
expect(mocks.mkdir).toHaveBeenCalledTimes(2);
expect(mocks.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
expect(mocks.logger.info).toHaveBeenCalledWith('Ensured multer storage directories exist.');
expect(mocks.logger.error).not.toHaveBeenCalled();
});
it('should log an error if directory creation fails', async () => {
// Arrange
const error = new Error('Permission denied');
mocks.mkdir.mockRejectedValue(error);
// Act
await import('./multer.middleware');
// Assert
expect(mocks.mkdir).toHaveBeenCalled();
expect(mocks.logger.error).toHaveBeenCalledWith(
{ error },
'Failed to create multer storage directories on startup.',
);
});
});

View File

@@ -36,6 +36,10 @@ const getStorageConfig = (type: StorageType) => {
// This should ideally not happen if auth middleware runs first.
return cb(new Error('User not authenticated for avatar upload'), '');
}
if (process.env.NODE_ENV === 'test') {
// Use a predictable filename for test avatars for easy cleanup.
return cb(null, `test-avatar${path.extname(file.originalname) || '.png'}`);
}
const uniqueSuffix = `${user.user.user_id}-${Date.now()}${path.extname(
file.originalname,
)}`;

View File

@@ -437,45 +437,36 @@ describe('ProfileManager', () => {
});
it('should automatically geocode address after user stops typing (using fake timers)', async () => {
// Use real timers for the initial async render and data fetch
vi.useRealTimers();
// Use fake timers for the entire test to control the debounce.
vi.useFakeTimers();
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)),
);
console.log('[TEST LOG] Rendering for automatic geocode test (Real Timers + Wait)');
render(<ProfileManager {...defaultAuthenticatedProps} />);
console.log('[TEST LOG] Waiting for initial address load...');
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
console.log('[TEST LOG] Initial address loaded. Changing city...');
// Wait for initial async address load to complete by flushing promises.
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
// Change address, geocode should not be called immediately
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
// Switch to fake timers to control the debounce timeout
vi.useFakeTimers();
// Advance timers to trigger the debounced function
act(() => {
vi.advanceTimersByTime(1500); // Must match debounce delay in useProfileAddress
// Advance timers to fire the debounce and resolve the subsequent geocode promise.
await act(async () => {
await vi.runAllTimersAsync();
});
console.log('[TEST LOG] Wait complete. Checking results.');
// Switch back to real timers to allow the async geocodeAddress promise to resolve
vi.useRealTimers();
// Now wait for the UI to update after the promise resolves
await waitFor(() => {
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
expect.stringContaining('NewCity'),
expect.anything(),
);
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
});
// Now check the final result.
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
expect.stringContaining('NewCity'),
expect.anything(),
);
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
});
it('should not geocode if address already has coordinates (using fake timers)', async () => {
@@ -745,6 +736,9 @@ describe('ProfileManager', () => {
});
it('should handle account deletion flow', async () => {
// Use fake timers to control the setTimeout call for the entire test.
vi.useFakeTimers();
render(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
@@ -762,27 +756,20 @@ describe('ProfileManager', () => {
fireEvent.submit(screen.getByTestId('delete-account-form'));
// Confirm in the modal
const confirmButton = await screen.findByRole('button', { name: /yes, delete my account/i });
// Use getByRole since the modal appears synchronously after the form submit.
const confirmButton = screen.getByRole('button', { name: /yes, delete my account/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalledWith(
'correctpassword',
expect.objectContaining({ signal: expect.anything() }),
);
expect(notifySuccess).toHaveBeenCalledWith(
'Account deleted successfully. You will be logged out shortly.',
);
});
// Now, switch to fake timers to control the setTimeout.
vi.useFakeTimers();
// Advance timers to trigger the logout and close
act(() => {
vi.advanceTimersByTime(3000);
// The async deleteAccount call is now pending. We need to flush promises
// and then advance the timers to run the subsequent setTimeout.
// `runAllTimersAsync` will resolve pending promises and run timers recursively.
await act(async () => {
await vi.runAllTimersAsync();
});
// Now that all timers and promises have been flushed, we can check the final state.
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalled();
expect(notifySuccess).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
expect(mockOnSignOut).toHaveBeenCalled();
});

View File

@@ -83,36 +83,6 @@ describe('AI Routes (/api/ai)', () => {
});
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
describe('Module-level error handling', () => {
it('should log an error if storage path creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => {
const mockFs = {
...fs,
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
};
return { ...mockFs, default: mockFs };
});
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the mkdirSync call
await import('./ai.routes');
// Assert
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
expect(logger.error).toHaveBeenCalledWith(
{ error: 'EACCES: permission denied' },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
vi.doUnmock('node:fs'); // Cleanup
});
});
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
describe('Diagnostic Middleware Error Handling', () => {
it('should log an error if logger.debug throws an object with a message property', async () => {

View File

@@ -456,11 +456,6 @@ describe('Background Job Service', () => {
it('should handle unhandled rejections in the analytics report cron wrapper', async () => {
const infoError = new Error('Logger info failed');
// Make logger.info throw, which is outside the try/catch in the cron job.
vi.mocked(globalMockLogger.info).mockImplementation(() => {
throw infoError;
});
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
@@ -469,6 +464,11 @@ describe('Background Job Service', () => {
globalMockLogger,
);
// Make logger.info throw, which is outside the try/catch in the cron job.
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
@@ -476,6 +476,7 @@ describe('Background Job Service', () => {
{ err: infoError }, // The implementation uses `err` key here
'[BackgroundJob] Unhandled rejection in analytics report cron wrapper.',
);
infoSpy.mockRestore();
});
it('should enqueue a weekly analytics job when the third cron job function is executed', async () => {
@@ -542,10 +543,6 @@ describe('Background Job Service', () => {
it('should handle unhandled rejections in the weekly analytics report cron wrapper', async () => {
const infoError = new Error('Logger info failed');
vi.mocked(globalMockLogger.info).mockImplementation(() => {
throw infoError;
});
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
@@ -554,6 +551,10 @@ describe('Background Job Service', () => {
globalMockLogger,
);
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
@@ -561,6 +562,7 @@ describe('Background Job Service', () => {
{ err: infoError },
'[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.',
);
infoSpy.mockRestore();
});
it('should enqueue a token cleanup job when the fourth cron job function is executed', async () => {
@@ -624,10 +626,6 @@ describe('Background Job Service', () => {
it('should handle unhandled rejections in the token cleanup cron wrapper', async () => {
const infoError = new Error('Logger info failed');
vi.mocked(globalMockLogger.info).mockImplementation(() => {
throw infoError;
});
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
@@ -636,6 +634,10 @@ describe('Background Job Service', () => {
globalMockLogger,
);
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const tokenCleanupCallback = mockCronSchedule.mock.calls[3][1];
await tokenCleanupCallback();
@@ -643,6 +645,7 @@ describe('Background Job Service', () => {
{ err: infoError },
'[BackgroundJob] Unhandled rejection in token cleanup cron wrapper.',
);
infoSpy.mockRestore();
});
it('should log a critical error if scheduling fails', () => {