moar unit test !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 6m9s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 6m9s
This commit is contained in:
@@ -261,6 +261,26 @@ router.post('/refresh-token', async (req: Request, res: Response) => {
|
||||
res.json({ token: newAccessToken });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout - Logs the user out by invalidating their refresh token.
|
||||
* It clears the refresh token from the database and instructs the client to
|
||||
* expire the `refreshToken` cookie.
|
||||
*/
|
||||
router.post('/logout', async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (refreshToken) {
|
||||
// Invalidate the token in the database so it cannot be used again.
|
||||
// We don't need to wait for this to finish to respond to the user.
|
||||
// This now calls the newly created function in user.db.ts.
|
||||
db.deleteRefreshToken(refreshToken).catch((err: Error) => {
|
||||
logger.error('Failed to delete refresh token from DB during logout.', { error: err });
|
||||
});
|
||||
}
|
||||
// Instruct the browser to clear the cookie by setting its expiration to the past.
|
||||
res.cookie('refreshToken', '', { httpOnly: true, expires: new Date(0), secure: process.env.NODE_ENV === 'production' });
|
||||
res.status(200).json({ message: 'Logged out successfully.' });
|
||||
});
|
||||
|
||||
// --- OAuth Routes ---
|
||||
|
||||
// const handleOAuthCallback = (req: Request, res: Response) => {
|
||||
|
||||
@@ -333,6 +333,21 @@ export async function findUserByRefreshToken(refreshToken: string): Promise<{ us
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a refresh token from the database by setting it to NULL.
|
||||
* This is used to invalidate a user's session during logout.
|
||||
* @param refreshToken The refresh token to delete.
|
||||
*/
|
||||
export async function deleteRefreshToken(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
// Set the refresh_token to NULL for the user who has this token.
|
||||
await getPool().query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [refreshToken]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in deleteRefreshToken:', { error });
|
||||
// Do not re-throw, as failing to invalidate a token is not a critical user-facing error.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a password reset token for a user.
|
||||
* @param userId The UUID of the user.
|
||||
|
||||
140
src/services/queueService.server.test.ts
Normal file
140
src/services/queueService.server.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// src/services/queueService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { Job } from 'bullmq';
|
||||
import { flyerWorkerProcessor } from './queueService.server';
|
||||
import * as aiService from './aiService.server';
|
||||
import * as db from './db/index.db';
|
||||
import * as imageProcessor from '../utils/imageProcessor';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./aiService.server');
|
||||
vi.mock('./db/index.db');
|
||||
vi.mock('../utils/imageProcessor');
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock child_process.exec
|
||||
const mockExec = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
exec: (command: string, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
mockExec(command, callback);
|
||||
// Simulate a successful execution by default
|
||||
callback(null, 'success', '');
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fs/promises for file cleanup tests
|
||||
const mockUnlink = vi.fn();
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAiService = aiService as Mocked<typeof aiService>;
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
const mockedImageProcessor = imageProcessor as Mocked<typeof imageProcessor>;
|
||||
|
||||
describe('Flyer Worker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Provide default successful mock implementations
|
||||
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({
|
||||
store_name: 'Mock Store',
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
store_address: '123 Mock St',
|
||||
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Test Category' }],
|
||||
});
|
||||
mockedDb.createFlyerAndItems.mockResolvedValue({
|
||||
flyer_id: 1,
|
||||
file_name: 'test.jpg',
|
||||
image_url: 'test.jpg',
|
||||
item_count: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
|
||||
mockedDb.getAllMasterItems.mockResolvedValue([]);
|
||||
mockedDb.logActivity.mockResolvedValue();
|
||||
});
|
||||
|
||||
const createMockJob = (data: any): Job => {
|
||||
return {
|
||||
id: 'job-1',
|
||||
data,
|
||||
updateProgress: vi.fn(),
|
||||
opts: { attempts: 3 },
|
||||
attemptsMade: 1,
|
||||
} as unknown as Job;
|
||||
};
|
||||
|
||||
it('should process an image file successfully', async () => {
|
||||
const jobData = {
|
||||
filePath: '/tmp/flyer.jpg',
|
||||
originalFileName: 'flyer.jpg',
|
||||
checksum: 'checksum-123',
|
||||
};
|
||||
const job = createMockJob(jobData);
|
||||
|
||||
const result = await flyerWorkerProcessor(job);
|
||||
|
||||
expect(result).toEqual({ flyerId: 1 });
|
||||
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.logActivity).toHaveBeenCalledTimes(1);
|
||||
expect(mockExec).not.toHaveBeenCalled(); // Should not call exec for an image
|
||||
});
|
||||
|
||||
it('should convert a PDF and process its images', async () => {
|
||||
const jobData = {
|
||||
filePath: '/tmp/flyer.pdf',
|
||||
originalFileName: 'flyer.pdf',
|
||||
checksum: 'checksum-pdf',
|
||||
};
|
||||
const job = createMockJob(jobData);
|
||||
|
||||
// Mock readdir to return the "converted" files
|
||||
const fs = await import('node:fs/promises');
|
||||
// The readdir function returns Dirent objects, not strings. We can cast to `any` here
|
||||
// as we only need the `name` property which we are providing as a string.
|
||||
vi.mocked(fs.default.readdir).mockResolvedValue(['flyer-1.jpg', 'flyer-2.jpg'] as any);
|
||||
|
||||
await flyerWorkerProcessor(job);
|
||||
|
||||
// Verify that pdftocairo was called
|
||||
expect(mockExec).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pdftocairo -jpeg -r 150'),
|
||||
expect.any(Function)
|
||||
);
|
||||
// Verify AI service was called with the converted images
|
||||
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: expect.stringContaining('flyer-1.jpg') }),
|
||||
expect.objectContaining({ path: expect.stringContaining('flyer-2.jpg') }),
|
||||
]),
|
||||
expect.any(Array), // masterItems
|
||||
undefined, // submitterIp
|
||||
undefined // userProfileAddress
|
||||
);
|
||||
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the AI service fails', async () => {
|
||||
const jobData = {
|
||||
filePath: '/tmp/flyer.jpg',
|
||||
originalFileName: 'flyer.jpg',
|
||||
checksum: 'checksum-fail',
|
||||
};
|
||||
const job = createMockJob(jobData);
|
||||
const aiError = new Error('AI model exploded');
|
||||
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);
|
||||
|
||||
await expect(flyerWorkerProcessor(job)).rejects.toThrow('AI model exploded');
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
|
||||
});
|
||||
});
|
||||
@@ -96,12 +96,11 @@ interface CleanupJobData {
|
||||
}
|
||||
|
||||
/**
|
||||
* The main worker process for handling flyer jobs.
|
||||
* This should be run as a separate process.
|
||||
* Processor function for the flyer-processing queue.
|
||||
* This is exported for testability.
|
||||
* @param job The job to process.
|
||||
*/
|
||||
export const flyerWorker = new Worker<FlyerJobData>(
|
||||
'flyer-processing',
|
||||
async (job: Job<FlyerJobData>) => {
|
||||
export const flyerWorkerProcessor = async (job: Job<FlyerJobData>) => {
|
||||
const { filePath, originalFileName, checksum, userId, submitterIp, userProfileAddress } = job.data;
|
||||
const createdImagePaths: string[] = [];
|
||||
let jobSucceeded = false;
|
||||
@@ -226,7 +225,11 @@ export const flyerWorker = new Worker<FlyerJobData>(
|
||||
logger.warn(`[Worker] Job ${job.id} failed. Temporary files will not be cleaned up to allow for manual inspection.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const flyerWorker = new Worker<FlyerJobData>(
|
||||
'flyer-processing',
|
||||
flyerWorkerProcessor,
|
||||
{
|
||||
connection,
|
||||
// Control the number of concurrent jobs. This directly limits parallel calls to the AI API.
|
||||
|
||||
@@ -63,6 +63,20 @@ describe('Authentication API Integration', () => {
|
||||
expect(errorData.message).toBe('Incorrect email or password.');
|
||||
});
|
||||
|
||||
it('should fail to log in with a non-existent email', async () => {
|
||||
const nonExistentEmail = 'nobody-here@example.com';
|
||||
const anyPassword = 'any-password';
|
||||
|
||||
// The loginUser function returns a Response object. We check its status.
|
||||
const response = await loginUser(nonExistentEmail, anyPassword, false);
|
||||
expect(response.ok).toBe(false);
|
||||
const errorData = await response.json();
|
||||
// Security best practice: the error message should be identical for wrong password and wrong email
|
||||
// to prevent user enumeration attacks.
|
||||
expect(errorData.message).toBe('Incorrect email or password.');
|
||||
});
|
||||
|
||||
|
||||
it('should successfully refresh an access token using a refresh token cookie', async () => {
|
||||
// Arrange: Log in to get a fresh, valid refresh token cookie for this specific test.
|
||||
// This ensures the test is self-contained and not affected by other tests.
|
||||
@@ -86,4 +100,47 @@ describe('Authentication API Integration', () => {
|
||||
const data = await response.json();
|
||||
expect(data.token).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('should fail to refresh an access token with an invalid refresh token cookie', async () => {
|
||||
// Arrange: Create a fake/invalid cookie.
|
||||
const invalidRefreshTokenCookie = 'refreshToken=this-is-not-a-valid-token';
|
||||
|
||||
// Act: Make a request to the refresh-token endpoint with the invalid cookie.
|
||||
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
||||
const response = await fetch(`${apiUrl}/auth/refresh-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Cookie': invalidRefreshTokenCookie,
|
||||
},
|
||||
});
|
||||
|
||||
// Assert: Check for a 403 Forbidden response.
|
||||
expect(response.ok).toBe(false);
|
||||
expect(response.status).toBe(403);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Invalid or expired refresh token.');
|
||||
});
|
||||
|
||||
it('should successfully log out and clear the refresh token cookie', async () => {
|
||||
// Arrange: Log in to get a valid refresh token cookie.
|
||||
const loginResponse = await loginUser('admin@example.com', 'adminpass', true);
|
||||
const setCookieHeader = loginResponse.headers.get('set-cookie');
|
||||
const refreshTokenCookie = setCookieHeader?.split(';')[0];
|
||||
expect(refreshTokenCookie).toBeDefined();
|
||||
|
||||
// Act: Make a request to the new logout endpoint, including the cookie.
|
||||
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
||||
const response = await fetch(`${apiUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Cookie': refreshTokenCookie!,
|
||||
},
|
||||
});
|
||||
|
||||
// Assert: Check for a successful response and a cookie-clearing header.
|
||||
expect(response.ok).toBe(true);
|
||||
const logoutSetCookieHeader = response.headers.get('set-cookie');
|
||||
expect(logoutSetCookieHeader).toContain('refreshToken=;');
|
||||
expect(logoutSetCookieHeader).toContain('Max-Age=0');
|
||||
});
|
||||
});
|
||||
34
src/tests/integration/flyer.integration.test.ts
Normal file
34
src/tests/integration/flyer.integration.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/tests/integration/flyer.integration.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import type { Flyer } from '../../types';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('Public Flyer API Routes Integration Tests', () => {
|
||||
describe('GET /api/flyers', () => {
|
||||
it('should return a list of flyers', async () => {
|
||||
// Act: Call the API endpoint using the client function.
|
||||
const response = await apiClient.fetchFlyers();
|
||||
const flyers: Flyer[] = await response.json();
|
||||
|
||||
// Assert: Verify the response is successful and contains the expected data structure.
|
||||
expect(response.ok).toBe(true);
|
||||
expect(flyers).toBeInstanceOf(Array);
|
||||
|
||||
// The seed script creates at least one flyer, so we expect the array not to be empty.
|
||||
expect(flyers.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the shape of the first flyer object to ensure it matches the expected type.
|
||||
const firstFlyer = flyers[0];
|
||||
expect(firstFlyer).toHaveProperty('flyer_id');
|
||||
expect(firstFlyer).toHaveProperty('file_name');
|
||||
expect(firstFlyer).toHaveProperty('image_url');
|
||||
expect(firstFlyer).toHaveProperty('store');
|
||||
expect(firstFlyer.store).toHaveProperty('store_id');
|
||||
expect(firstFlyer.store).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/tests/integration/server.integration.test.ts
Normal file
65
src/tests/integration/server.integration.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/tests/integration/server.integration.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('Server Initialization Smoke Test', () => {
|
||||
it('should import the server app without crashing', () => {
|
||||
// This test's primary purpose is to ensure that all top-level code in `server.ts`
|
||||
// can execute without throwing an error. This catches issues like syntax errors,
|
||||
// bad imports, or problems with initial setup code that runs on import.
|
||||
expect(app).toBeDefined();
|
||||
expect(typeof app).toBe('function'); // An Express app is a function.
|
||||
});
|
||||
|
||||
it('should respond with 200 OK and "pong" for GET /api/health/ping', async () => {
|
||||
// Use supertest to make a request to the Express app instance.
|
||||
const response = await supertest(app).get('/api/health/ping');
|
||||
|
||||
// Assert that the server responds with the correct status code and body.
|
||||
// This confirms that the routing is set up correctly and the endpoint is reachable.
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('pong');
|
||||
});
|
||||
|
||||
it('should respond with 200 OK for GET /api/health/db-schema', async () => {
|
||||
// Use supertest to make a request to the Express app instance.
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
|
||||
// Assert that the server responds with a success message.
|
||||
// This confirms that the database connection is working and the essential tables exist.
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
message: 'All required database tables exist.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with 200 OK for GET /api/health/storage', async () => {
|
||||
// Use supertest to make a request to the Express app instance.
|
||||
const response = await supertest(app).get('/api/health/storage');
|
||||
|
||||
// Assert that the server responds with a success message.
|
||||
// This confirms that the directory specified by STORAGE_PATH exists and is writable
|
||||
// by the application user, which is critical for file uploads.
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toContain('is accessible and writable');
|
||||
});
|
||||
|
||||
it('should respond with 200 OK for GET /api/health/redis', async () => {
|
||||
// Use supertest to make a request to the Express app instance.
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
|
||||
// Assert that the server responds with a success message.
|
||||
// This confirms that the connection to the Redis server is active, which is
|
||||
// essential for the background job queueing system (BullMQ).
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('Redis connection is healthy.');
|
||||
});
|
||||
});
|
||||
@@ -63,14 +63,21 @@ export default defineConfig({
|
||||
reportOnFailure: true, // This ensures the report generates even if tests fail
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
// Refine exclusions to be more comprehensive
|
||||
// By excluding scripts, setup files, and type definitions, we get a more accurate
|
||||
// picture of the test coverage for the actual application logic.
|
||||
exclude: [
|
||||
'src/main.tsx',
|
||||
'src/index.tsx', // Application entry point
|
||||
'src/main.tsx', // A common alternative entry point name
|
||||
'src/types.ts',
|
||||
'src/tests/**', // Exclude all test setup and helper files
|
||||
'src/vitest.setup.ts', // Global test setup config
|
||||
'src/**/*.test.{ts,tsx}', // Exclude test files themselves
|
||||
'src/**/*.stories.{ts,tsx}', // Exclude Storybook stories
|
||||
'src/**/*.d.ts', // Exclude type definition files
|
||||
'src/components/icons/**', // Exclude icon components if they are simple wrappers
|
||||
'src/db/seed.ts', // Database seeding script
|
||||
'src/db/seed_admin_account.ts', // Database seeding script
|
||||
'src/db/backup_user.ts', // Database backup script
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user