moar unit test !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 6m9s

This commit is contained in:
2025-12-07 01:26:17 -08:00
parent 08e73c4ca1
commit 6d5cafda38
8 changed files with 348 additions and 7 deletions

View File

@@ -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) => {

View File

@@ -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.

View 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' });
});
});

View File

@@ -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.

View File

@@ -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');
});
});

View 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');
});
});
});

View 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.');
});
});

View File

@@ -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
],
},
},