fix tests ugh
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m59s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m59s
This commit is contained in:
@@ -248,7 +248,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('PUT /comments/:id/status should return 400 for an invalid status', async () => {
|
||||
// Mock the database call to prevent it from executing. The route should validate
|
||||
// the status and return 400 before the DB is ever touched.
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockRejectedValue(new Error('DB Error'));
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockRejectedValue(new Error('This should not be called'));
|
||||
const response = await supertest(app).put('/api/admin/comments/301').send({ status: 'invalid-status' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toContain('A valid status');
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock('../services/db/index.db', async () => {
|
||||
updateUserPassword: vi.fn(),
|
||||
deleteResetToken: vi.fn(),
|
||||
findUserByRefreshToken: vi.fn(),
|
||||
deleteRefreshToken: vi.fn(),
|
||||
},
|
||||
adminRepo: {
|
||||
logActivity: vi.fn(),
|
||||
@@ -125,6 +126,10 @@ vi.mock('bcrypt', async (importOriginal) => {
|
||||
// Import the router AFTER mocks are established
|
||||
import authRouter from './auth.routes';
|
||||
import * as db from '../services/db/index.db'; // This was a duplicate, fixed.
|
||||
|
||||
// Import the actual class so we can spy on its prototype
|
||||
import { UserRepository } from '../services/db/user.db';
|
||||
|
||||
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
|
||||
|
||||
// --- 4. App Setup ---
|
||||
@@ -151,6 +156,7 @@ app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
describe('Auth Routes (/api/auth)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks(); // Restore spies on prototypes
|
||||
});
|
||||
|
||||
describe('POST /register', () => {
|
||||
@@ -169,13 +175,10 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
preferences: {}
|
||||
};
|
||||
|
||||
// Mock the transactional queries
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||
.mockResolvedValueOnce({ rows: [{ user_id: 'new-user-id' }] }) // INSERT user
|
||||
.mockResolvedValueOnce({ rows: [mockNewUser] }) // SELECT profile
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
// Spy on the prototype to mock the method for the instance created inside the route
|
||||
const createUserSpy = vi.spyOn(UserRepository.prototype, 'createUser').mockResolvedValue(mockNewUser);
|
||||
// Mock the transactional client to handle BEGIN/COMMIT
|
||||
mockClient.query.mockResolvedValue({ rows: [] });
|
||||
|
||||
// Mock the non-transactional calls
|
||||
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
|
||||
@@ -197,6 +200,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
expect(response.body.message).toBe('User registered successfully!');
|
||||
expect(response.body.user.email).toBe(newUserEmail);
|
||||
expect(response.body.token).toBeTypeOf('string');
|
||||
expect(createUserSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject registration with a weak password', async () => {
|
||||
@@ -217,13 +221,10 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
const dbError = new UniqueConstraintError('User with that email already exists.');
|
||||
(dbError as any).code = '23505'; // Simulate PG error code
|
||||
|
||||
// Mock the transactional queries to fail on user insertion
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||
.mockRejectedValueOnce(dbError) // INSERT user fails
|
||||
.mockResolvedValueOnce({ rows: [] } // ROLLBACK
|
||||
);
|
||||
// Spy on the prototype to mock the method for the instance created inside the route
|
||||
const createUserSpy = vi.spyOn(UserRepository.prototype, 'createUser').mockRejectedValue(dbError);
|
||||
// Mock the transactional client to handle BEGIN/ROLLBACK
|
||||
mockClient.query.mockResolvedValue({ rows: [] });
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
@@ -231,6 +232,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
expect(response.status).toBe(409); // 409 Conflict
|
||||
expect(response.body.message).toBe('User with that email already exists.');
|
||||
expect(createUserSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject registration if email or password are not provided', async () => {
|
||||
@@ -387,4 +389,26 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /logout', () => {
|
||||
it('should clear the refresh token cookie and return a success message', async () => {
|
||||
// Arrange
|
||||
vi.mocked(db.userRepo.deleteRefreshToken).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', 'refreshToken=some-valid-token');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully.');
|
||||
|
||||
// Check that the 'set-cookie' header is trying to expire the cookie
|
||||
const setCookieHeader = response.headers['set-cookie'];
|
||||
expect(setCookieHeader).toBeDefined();
|
||||
expect(setCookieHeader[0]).toContain('refreshToken=;');
|
||||
expect(setCookieHeader[0]).toContain('Expires=Thu, 01 Jan 1970');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,29 @@ import { UniqueConstraintError } from '../services/db/errors.db';
|
||||
import { getPool } from '../services/db/connection.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { sendPasswordResetEmail } from '../services/emailService.server';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||
|
||||
/**
|
||||
* Validates the strength of a password using zxcvbn.
|
||||
* @param password The password to check.
|
||||
* @returns An object with `isValid` and an optional `feedback` message.
|
||||
*/
|
||||
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
|
||||
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
||||
const strength = zxcvbn(password);
|
||||
|
||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||
const feedbackMessage = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||
return { isValid: false, feedback: `Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim() };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
// Conditionally disable rate limiting for the test environment
|
||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
||||
|
||||
@@ -50,12 +68,10 @@ router.post('/register', async (req, res, next) => {
|
||||
}
|
||||
|
||||
// --- Password Strength Check ---
|
||||
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
||||
const strength = zxcvbn(password);
|
||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||
logger.warn(`Weak password rejected during registration for email: ${email}. Score: ${strength.score}`);
|
||||
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||
return res.status(400).json({ message: `Password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.isValid) {
|
||||
logger.warn(`Weak password rejected during registration for email: ${email}.`);
|
||||
return res.status(400).json({ message: passwordValidation.feedback });
|
||||
}
|
||||
|
||||
const client = await getPool().connect();
|
||||
@@ -83,14 +99,14 @@ router.post('/register', async (req, res, next) => {
|
||||
return res.status(409).json({ message: error.message });
|
||||
}
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Transaction failed during user registration.', { error });
|
||||
logger.error(`Transaction failed during user registration for email: ${email}.`, { error });
|
||||
return next(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Safe access to prevent crashing if the returned object structure is unexpected during tests
|
||||
const userEmail = newUser?.user?.email || 'unknown';
|
||||
const userEmail = (newUser as UserProfile)?.user?.email || 'unknown';
|
||||
const userId = newUser?.user_id || 'unknown';
|
||||
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
|
||||
|
||||
@@ -136,7 +152,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
// --- END DEBUG LOGGING ---
|
||||
const { rememberMe } = req.body;
|
||||
if (err) {
|
||||
logger.error('Login authentication error in /login route:', { error: err });
|
||||
logger.error(`Login authentication error in /login route for email: ${req.body.email}`, { error: err });
|
||||
return next(err);
|
||||
}
|
||||
if (!user) {
|
||||
@@ -163,7 +179,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
return res.json({ user: userResponse, token: accessToken });
|
||||
} catch (tokenErr) {
|
||||
logger.error('Failed to save refresh token during login:', { error: tokenErr });
|
||||
logger.error(`Failed to save refresh token during login for user: ${typedUser.email}`, { error: tokenErr });
|
||||
return next(tokenErr);
|
||||
}
|
||||
})(req, res, next);
|
||||
@@ -208,6 +224,7 @@ router.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) =>
|
||||
if (process.env.NODE_ENV === 'test' && user) responsePayload.token = token;
|
||||
res.status(200).json(responsePayload);
|
||||
} catch (error) {
|
||||
logger.error(`An error occurred during /forgot-password for email: ${email}`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -234,12 +251,10 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
|
||||
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_SCORE = 3;
|
||||
const strength = zxcvbn(newPassword);
|
||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||
logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}. Score: ${strength.score}`);
|
||||
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||
return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}.`);
|
||||
return res.status(400).json({ message: passwordValidation.feedback });
|
||||
}
|
||||
|
||||
const saltRounds = 10;
|
||||
@@ -259,6 +274,7 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
|
||||
|
||||
res.status(200).json({ message: 'Password has been reset successfully.' });
|
||||
} catch (error) {
|
||||
logger.error(`An error occurred during password reset.`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -267,18 +283,25 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
|
||||
router.post('/refresh-token', async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
}
|
||||
|
||||
const user = await userRepo.findUserByRefreshToken(refreshToken);
|
||||
if (!user) {
|
||||
try {
|
||||
const user = await userRepo.findUserByRefreshToken(refreshToken);
|
||||
if (!user) {
|
||||
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
|
||||
}
|
||||
|
||||
const payload = { user_id: user.user_id, email: user.email };
|
||||
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
} catch (error) {
|
||||
logger.error('An error occurred during /refresh-token.', { error });
|
||||
// Unlike other routes, we don't call next(error) here to avoid a server crash
|
||||
// and instead send a generic 500 error to the client.
|
||||
res.status(500).json({ message: 'An internal error occurred while refreshing the token.' });
|
||||
}
|
||||
|
||||
const payload = { user_id: user.user_id, email: user.email };
|
||||
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,16 @@ const JWT_SECRET = process.env.JWT_SECRET!;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* A type guard to check if an object is a UserProfile.
|
||||
* This is useful for safely accessing properties on `req.user`.
|
||||
* @param user The user object to check.
|
||||
* @returns True if the object is a UserProfile, false otherwise.
|
||||
*/
|
||||
function isUserProfile(user: unknown): user is UserProfile {
|
||||
return typeof user === 'object' && user !== null && 'user_id' in user && 'role' in user;
|
||||
}
|
||||
|
||||
// --- Passport Local Strategy (for email/password login) ---
|
||||
passport.use(new LocalStrategy(
|
||||
{
|
||||
@@ -26,8 +36,8 @@ passport.use(new LocalStrategy(
|
||||
},
|
||||
async (req: Request, email, password, done) => {
|
||||
try {
|
||||
// 1. Find the user in your PostgreSQL database by email.
|
||||
const user = await db.userRepo.findUserByEmail(email);
|
||||
// 1. Find the user by email, including their profile data for the JWT payload.
|
||||
const user = await db.userRepo.findUserWithProfileByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
// User not found
|
||||
@@ -80,9 +90,10 @@ passport.use(new LocalStrategy(
|
||||
// Reset failed login attempts upon successful login.
|
||||
await db.adminRepo.resetFailedLoginAttempts(user.user_id, req.ip ?? 'unknown');
|
||||
|
||||
// The password_hash is intentionally removed for security before returning the user object.
|
||||
const userWithoutHash = omit(user, ['password_hash']);
|
||||
logger.info(`User successfully authenticated: ${email}`);
|
||||
// The user object from `findUserWithProfileByEmail` already excludes the password hash.
|
||||
// This object will be passed to the /login route handler.
|
||||
const userWithoutHash = user;
|
||||
return done(null, userWithoutHash);
|
||||
} catch (err) {
|
||||
logger.error('Error during local authentication strategy:', { error: err });
|
||||
@@ -225,13 +236,15 @@ passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
|
||||
// --- Middleware for Admin Role Check ---
|
||||
export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
// This middleware should run *after* passport.authenticate('jwt', ...)
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Use the type guard for safer access to req.user
|
||||
const userProfile = req.user;
|
||||
|
||||
if (userProfile && userProfile.role === 'admin') {
|
||||
if (isUserProfile(userProfile) && userProfile.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
logger.warn(`Admin access denied for user: ${userProfile?.user_id}`);
|
||||
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
||||
const userIdForLog = isUserProfile(userProfile) ? userProfile.user_id : 'unknown';
|
||||
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,6 +48,22 @@ const server = setupServer(
|
||||
}
|
||||
} else if (contentType?.includes('multipart/form-data')) {
|
||||
body = await request.formData();
|
||||
// FIX: When MSW processes FormData, the file's 'name' property is lost in JSDOM.
|
||||
// To fix the tests, we manually reconstruct a File-like object with the name
|
||||
// from the headers for our spy. This makes the test assertions pass.
|
||||
const fileEntry = Array.from((body as FormData).entries()).find(
|
||||
([key, value]) => value instanceof File
|
||||
);
|
||||
if (fileEntry) {
|
||||
const [key, file] = fileEntry as [string, File];
|
||||
// Create a new object that looks like a File for the spy
|
||||
(body as FormData).set(key, {
|
||||
name: file.name, // JSDOM preserves the name on the original File object
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
// The test doesn't need the content, so we can omit it.
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
requestSpy({
|
||||
@@ -96,10 +112,10 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
expect(req.method).toBe('POST');
|
||||
// FIX: Use a duck-typing check for FormData to avoid environment-specific instance issues.
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
|
||||
const flyerFile = (req.body as FormData).get('flyerFile') as File;
|
||||
|
||||
const flyerFile = (req.body as FormData).get('flyerFile') as { name: string };
|
||||
const checksumValue = (req.body as FormData).get('checksum');
|
||||
|
||||
|
||||
expect(flyerFile.name).toBe('flyer.pdf');
|
||||
expect(checksumValue).toBe(checksum);
|
||||
});
|
||||
@@ -130,7 +146,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
expect(req.endpoint).toBe('check-flyer');
|
||||
expect(req.method).toBe('POST');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
const imageFile = (req.body as FormData).get('image') as File;
|
||||
const imageFile = (req.body as FormData).get('image') as { name: string };
|
||||
expect(imageFile.name).toBe('flyer.jpg');
|
||||
});
|
||||
});
|
||||
@@ -145,7 +161,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
|
||||
expect(req.endpoint).toBe('extract-address');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
const imageFile = (req.body as FormData).get('image') as File;
|
||||
const imageFile = (req.body as FormData).get('image') as { name: string };
|
||||
expect(imageFile.name).toBe('flyer.jpg');
|
||||
});
|
||||
});
|
||||
@@ -160,7 +176,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
|
||||
expect(req.endpoint).toBe('extract-logo');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
const imageFile = (req.body as FormData).get('images') as File;
|
||||
const imageFile = (req.body as FormData).get('images') as { name: string };
|
||||
expect(imageFile.name).toBe('logo.jpg');
|
||||
});
|
||||
});
|
||||
@@ -260,7 +276,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
expect(req.endpoint).toBe('rescan-area');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
|
||||
const imageFile = (req.body as FormData).get('image') as File;
|
||||
const imageFile = (req.body as FormData).get('image') as { name: string };
|
||||
const cropAreaValue = (req.body as FormData).get('cropArea');
|
||||
const extractionTypeValue = (req.body as FormData).get('extractionType');
|
||||
|
||||
|
||||
@@ -174,6 +174,30 @@ describe('User DB Service', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserWithProfileByEmail', () => {
|
||||
it('should query for a user and their profile by email', async () => {
|
||||
const mockUserWithProfile = { user_id: '123', email: 'test@example.com', full_name: 'Test User', role: 'user' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockUserWithProfile] });
|
||||
|
||||
const result = await userRepo.findUserWithProfileByEmail('test@example.com');
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('JOIN public.profiles'), ['test@example.com']);
|
||||
expect(result).toEqual(mockUserWithProfile);
|
||||
});
|
||||
|
||||
it('should return undefined if user is not found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
const result = await userRepo.findUserWithProfileByEmail('notfound@example.com');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(userRepo.findUserWithProfileByEmail('test@example.com')).rejects.toThrow('Failed to retrieve user with profile from database.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserById', () => {
|
||||
it('should query for a user by their ID', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PersonalizationRepository } from './personalization.db';
|
||||
* Defines the structure of a user object as returned from the database.
|
||||
*/
|
||||
interface DbUser {
|
||||
user_id: string; // UUID
|
||||
user_id: string;
|
||||
email: string;
|
||||
// The password_hash can be null for users who signed up via OAuth.
|
||||
password_hash: string | null;
|
||||
@@ -110,6 +110,29 @@ export class UserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their email and joins their profile data.
|
||||
* This is used by the LocalStrategy to get all necessary data for authentication and session creation in one query.
|
||||
* @param email The email of the user to find.
|
||||
* @returns A promise that resolves to the combined user and profile object or undefined if not found.
|
||||
*/
|
||||
async findUserWithProfileByEmail(email: string): Promise<(DbUser & Profile) | undefined> {
|
||||
logger.debug(`[DB findUserWithProfileByEmail] Searching for user with email: ${email}`);
|
||||
try {
|
||||
const query = `
|
||||
SELECT u.*, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.address_id
|
||||
FROM public.users u
|
||||
JOIN public.profiles p ON u.user_id = p.user_id
|
||||
WHERE u.email = $1;
|
||||
`;
|
||||
const res = await this.db.query<(DbUser & Profile)>(query, [email]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findUserWithProfileByEmail:', { error });
|
||||
throw new Error('Failed to retrieve user with profile from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their ID. Used by the JWT strategy to validate tokens.
|
||||
* @param id The UUID of the user to find.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { Job } from 'bullmq';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import type { FlyerJobData } from './flyerProcessingService.types';
|
||||
|
||||
// 1. Create hoisted mocks FIRST
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -27,7 +28,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { FlyerProcessingService, type FlyerJobData } from './flyerProcessingService.server';
|
||||
import { FlyerProcessingService } from './flyerProcessingService.server';
|
||||
import * as aiService from './aiService.server';
|
||||
import * as db from './db/index.db';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
@@ -188,7 +189,7 @@ describe('FlyerProcessingService', () => {
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI response validation failed. The returned data structure is incorrect.' });
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -199,7 +200,7 @@ describe('FlyerProcessingService', () => {
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: A generic error occurred during AI or DB processing.' });
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' });
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -210,4 +211,51 @@ describe('FlyerProcessingService', () => {
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_saveProcessedFlyerData (private method)', () => {
|
||||
it('should transform data, create flyer in DB, and log activity', async () => {
|
||||
// Arrange
|
||||
const mockExtractedData = {
|
||||
store_name: 'Test 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', master_item_id: 1 }],
|
||||
};
|
||||
const mockImagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
|
||||
const mockJobData = {
|
||||
originalFileName: 'flyer.jpg',
|
||||
checksum: 'checksum-123',
|
||||
userId: 'user-abc',
|
||||
};
|
||||
|
||||
// The transformer is already spied on in beforeEach, we can just check its call.
|
||||
const transformerSpy = vi.spyOn(FlyerDataTransformer.prototype, 'transform');
|
||||
|
||||
// The DB create function is also mocked in beforeEach.
|
||||
const mockNewFlyer = { flyer_id: 1, file_name: 'flyer.jpg', store_name: 'Test Store' };
|
||||
vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] } as any);
|
||||
|
||||
// Act: Access and call the private method for testing
|
||||
const result = await (service as any)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData);
|
||||
|
||||
// Assert
|
||||
// 1. Transformer was called correctly
|
||||
expect(transformerSpy).toHaveBeenCalledWith(mockExtractedData, mockImagePaths, mockJobData.originalFileName, mockJobData.checksum, mockJobData.userId);
|
||||
|
||||
// 2. DB function was called with the transformed data
|
||||
const transformedData = await transformerSpy.mock.results[0].value;
|
||||
expect(createFlyerAndItems).toHaveBeenCalledWith(transformedData.flyerData, transformedData.itemsForDb);
|
||||
|
||||
// 3. Activity was logged
|
||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'flyer_processed',
|
||||
details: { flyerId: mockNewFlyer.flyer_id, storeName: mockNewFlyer.store_name }
|
||||
}));
|
||||
|
||||
// 4. The method returned the new flyer
|
||||
expect(result).toEqual(mockNewFlyer);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,280 +1,260 @@
|
||||
// src/services/flyerProcessingService.server.ts
|
||||
import type { Job, JobsOptions } from 'bullmq';
|
||||
import path from 'path';
|
||||
// --- FIX REGISTRY ---
|
||||
//
|
||||
// 2024-07-30: Fixed `FlyerDataTransformer` mock to be a constructible class. The previous mock was not a constructor,
|
||||
// causing a `TypeError` when `FlyerProcessingService` tried to instantiate it with `new`.
|
||||
// --- END FIX REGISTRY ---
|
||||
// src/services/flyerProcessingService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { Job } from 'bullmq';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import type { FlyerJobData } from './flyerProcessingService.types';
|
||||
|
||||
import { logger } from './logger.server';
|
||||
import type { AIService } from './aiService.server';
|
||||
import type * as db from './db/index.db';
|
||||
// 1. Create hoisted mocks FIRST
|
||||
const mocks = vi.hoisted(() => ({
|
||||
unlink: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
// 2. Mock modules using the hoisted variables
|
||||
vi.mock('util', () => ({ promisify: () => mocks.execAsync }));
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
default: actual, // Ensure default export exists
|
||||
unlink: mocks.unlink,
|
||||
readdir: mocks.readdir,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
import { FlyerProcessingService, type FlyerJobData } from './flyerProcessingService.server';
|
||||
import * as aiService from './aiService.server';
|
||||
import * as db from './db/index.db';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
import { PdfConversionError, AiDataValidationError } from './processingErrors';
|
||||
import * as imageProcessor from '../utils/imageProcessor';
|
||||
import { FlyerDataTransformer } from './flyerDataTransformer';
|
||||
|
||||
// --- Start: Interfaces for Dependency Injection ---
|
||||
// Mock dependencies
|
||||
vi.mock('./aiService.server', () => ({
|
||||
aiService: {
|
||||
extractCoreDataFromFlyerImage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('./db/flyer.db', () => ({
|
||||
createFlyerAndItems: vi.fn(),
|
||||
}));
|
||||
vi.mock('./db/index.db', () => ({
|
||||
personalizationRepo: { getAllMasterItems: vi.fn() },
|
||||
adminRepo: { logActivity: vi.fn() },
|
||||
}));
|
||||
vi.mock('../utils/imageProcessor', () => ({
|
||||
generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'),
|
||||
}));
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }
|
||||
}));
|
||||
|
||||
export interface IFileSystem {
|
||||
readdir(path: string, options: { withFileTypes: true }): Promise<Dirent[]>;
|
||||
unlink(path: string): Promise<void>;
|
||||
}
|
||||
const mockedAiService = aiService as Mocked<typeof aiService>;
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
const mockedImageProcessor = imageProcessor as Mocked<typeof imageProcessor>;
|
||||
|
||||
export interface ICommandExecutor {
|
||||
(command: string): Promise<{ stdout: string; stderr: string }>;
|
||||
}
|
||||
describe('FlyerProcessingService', () => {
|
||||
let service: FlyerProcessingService;
|
||||
const mockCleanupQueue = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
|
||||
export interface FlyerJobData {
|
||||
filePath: string;
|
||||
originalFileName: string;
|
||||
checksum: string;
|
||||
userId?: string;
|
||||
submitterIp?: string;
|
||||
userProfileAddress?: string;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
interface CleanupJobData {
|
||||
flyerId: number;
|
||||
// An array of absolute file paths to be deleted. Made optional for manual cleanup triggers.
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the contract for a queue that can have cleanup jobs added to it.
|
||||
* This is used for dependency injection to avoid circular dependencies.
|
||||
*/
|
||||
interface ICleanupQueue {
|
||||
add(name: string, data: CleanupJobData, opts?: JobsOptions): Promise<Job<CleanupJobData>>;
|
||||
}
|
||||
|
||||
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
|
||||
const ExtractedFlyerItemSchema = z.object({
|
||||
item: z.string(),
|
||||
price_display: z.string(),
|
||||
price_in_cents: z.number().nullable(),
|
||||
quantity: z.string(),
|
||||
category_name: z.string(),
|
||||
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
|
||||
});
|
||||
|
||||
export const AiFlyerDataSchema = z.object({
|
||||
store_name: z.string().min(1, { message: "Store name cannot be empty" }),
|
||||
valid_from: z.string().nullable(),
|
||||
valid_to: z.string().nullable(),
|
||||
store_address: z.string().nullable(),
|
||||
items: z.array(ExtractedFlyerItemSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* This class encapsulates the business logic for processing a flyer from a file.
|
||||
* It handles PDF conversion, AI data extraction, and saving the results to the database.
|
||||
*/
|
||||
export class FlyerProcessingService {
|
||||
constructor(
|
||||
private ai: AIService,
|
||||
private database: typeof db,
|
||||
private fs: IFileSystem,
|
||||
private exec: ICommandExecutor,
|
||||
private cleanupQueue: ICleanupQueue,
|
||||
private transformer: FlyerDataTransformer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Converts a PDF file to a series of JPEG images using an external tool.
|
||||
* @param filePath The path to the PDF file.
|
||||
* @param job The BullMQ job instance for progress updates.
|
||||
* @returns A promise that resolves to an array of paths to the created image files.
|
||||
*/
|
||||
private async _convertPdfToImages(filePath: string, job: Job<FlyerJobData>): Promise<string[]> {
|
||||
logger.info(`[Worker] Starting PDF conversion for: ${filePath}`);
|
||||
await job.updateProgress({ message: 'Converting PDF to images...' });
|
||||
|
||||
const outputDir = path.dirname(filePath);
|
||||
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
|
||||
logger.debug(`[Worker] PDF output directory: ${outputDir}`);
|
||||
logger.debug(`[Worker] PDF output file prefix: ${outputFilePrefix}`);
|
||||
|
||||
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
|
||||
logger.info(`[Worker] Executing PDF conversion command: ${command}`);
|
||||
const { stdout, stderr } = await this.exec(command);
|
||||
|
||||
if (stdout) logger.debug(`[Worker] pdftocairo stdout for ${filePath}:`, { stdout });
|
||||
if (stderr) logger.warn(`[Worker] pdftocairo stderr for ${filePath}:`, { stderr });
|
||||
|
||||
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
|
||||
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
|
||||
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
|
||||
|
||||
const generatedImages = filesInDir
|
||||
.filter(f => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
|
||||
logger.debug(`[Worker] Filtered down to ${generatedImages.length} generated JPGs.`, {
|
||||
imageNames: generatedImages.map(f => f.name),
|
||||
// Spy on the real transformer's method and provide a mock implementation.
|
||||
// This is more robust than mocking the entire class constructor.
|
||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
||||
flyerData: { file_name: 'test.jpg', image_url: 'test.jpg', icon_url: 'icon.webp', checksum: 'checksum-123', store_name: 'Mock Store' } as any,
|
||||
itemsForDb: [],
|
||||
});
|
||||
|
||||
if (generatedImages.length === 0) {
|
||||
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
|
||||
logger.error(`[Worker] PdfConversionError: ${errorMessage}`, { stderr });
|
||||
throw new PdfConversionError(errorMessage, stderr);
|
||||
}
|
||||
// Default mock implementation for the promisified exec
|
||||
mocks.execAsync.mockResolvedValue({ stdout: 'success', stderr: '' });
|
||||
|
||||
return generatedImages.map(img => path.join(outputDir, img.name));
|
||||
}
|
||||
// Default mock for readdir returns an empty array of Dirent-like objects.
|
||||
mocks.readdir.mockResolvedValue([]);
|
||||
|
||||
/**
|
||||
* Prepares the input images for the AI service. If the input is a PDF, it's converted to images.
|
||||
* @param filePath The path to the original uploaded file.
|
||||
* @param job The BullMQ job instance.
|
||||
* @returns An object containing the final image paths for the AI and a list of any newly created image files.
|
||||
*/
|
||||
private async _prepareImageInputs(filePath: string, job: Job<FlyerJobData>): Promise<{ imagePaths: { path: string; mimetype: string }[], createdImagePaths: string[] }> {
|
||||
const fileExt = path.extname(filePath).toLowerCase();
|
||||
// Mock the file system adapter that will be passed to the service
|
||||
const mockFs = {
|
||||
readdir: mocks.readdir,
|
||||
unlink: mocks.unlink,
|
||||
};
|
||||
|
||||
if (fileExt === '.pdf') {
|
||||
const createdImagePaths = await this._convertPdfToImages(filePath, job);
|
||||
const imagePaths = createdImagePaths.map(p => ({ path: p, mimetype: 'image/jpeg' }));
|
||||
logger.info(`[Worker] Converted PDF to ${imagePaths.length} images.`);
|
||||
return { imagePaths, createdImagePaths };
|
||||
} else {
|
||||
logger.info(`[Worker] Processing as a single image file: ${filePath}`);
|
||||
const imagePaths = [{ path: filePath, mimetype: `image/${fileExt.slice(1)}` }];
|
||||
return { imagePaths, createdImagePaths: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the AI service to extract structured data from the flyer images.
|
||||
* @param imagePaths An array of paths and mimetypes for the images.
|
||||
* @param jobData The data from the BullMQ job.
|
||||
* @returns A promise that resolves to the validated, structured flyer data.
|
||||
*/
|
||||
private async _extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData) {
|
||||
logger.info(`[Worker] Starting AI data extraction for job ${jobData.checksum}.`);
|
||||
const { submitterIp, userProfileAddress } = jobData;
|
||||
const masterItems = await this.database.personalizationRepo.getAllMasterItems();
|
||||
logger.debug(`[Worker] Retrieved ${masterItems.length} master items for AI matching.`);
|
||||
|
||||
const extractedData = await this.ai.extractCoreDataFromFlyerImage(
|
||||
imagePaths,
|
||||
masterItems,
|
||||
submitterIp,
|
||||
userProfileAddress
|
||||
// Instantiate the service with all its dependencies mocked
|
||||
service = new FlyerProcessingService(
|
||||
mockedAiService.aiService,
|
||||
mockedDb,
|
||||
mockFs,
|
||||
mocks.execAsync,
|
||||
mockCleanupQueue,
|
||||
new FlyerDataTransformer()
|
||||
);
|
||||
|
||||
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.flatten();
|
||||
logger.error('[Worker] AI response failed validation.', {
|
||||
errors,
|
||||
rawData: extractedData,
|
||||
});
|
||||
throw new AiDataValidationError('AI response validation failed. The returned data structure is incorrect.', errors, extractedData);
|
||||
}
|
||||
|
||||
logger.info(`[Worker] AI extracted ${extractedData.items.length} items.`);
|
||||
return validationResult.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the extracted flyer data to the database.
|
||||
* @param extractedData The structured data from the AI.
|
||||
* @param imagePaths The paths to the flyer images.
|
||||
* @param jobData The data from the BullMQ job.
|
||||
* @returns A promise that resolves to the newly created flyer record.
|
||||
*/
|
||||
private async _saveProcessedFlyerData(
|
||||
extractedData: z.infer<typeof AiFlyerDataSchema>,
|
||||
imagePaths: { path: string; mimetype: string }[],
|
||||
jobData: FlyerJobData
|
||||
) {
|
||||
logger.info(`[Worker] Preparing to save extracted data to database for job ${jobData.checksum}.`);
|
||||
|
||||
// 1. Transform the AI data into database-ready records.
|
||||
const { flyerData, itemsForDb } = await this.transformer.transform(
|
||||
extractedData,
|
||||
imagePaths,
|
||||
jobData.originalFileName,
|
||||
jobData.checksum,
|
||||
jobData.userId
|
||||
);
|
||||
|
||||
// 2. Save the transformed data to the database.
|
||||
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb);
|
||||
logger.info(`[Worker] Successfully saved new flyer ID: ${newFlyer.flyer_id}`);
|
||||
|
||||
await this.database.adminRepo.logActivity({ userId: jobData.userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } });
|
||||
|
||||
return newFlyer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a job to clean up temporary files associated with a flyer upload.
|
||||
* @param flyerId The ID of the processed flyer.
|
||||
* @param paths An array of file paths to be deleted.
|
||||
*/
|
||||
private async _enqueueCleanup(flyerId: number, paths: string[]): Promise<void> {
|
||||
if (paths.length === 0) return;
|
||||
|
||||
await this.cleanupQueue.add('cleanup-flyer-files', { flyerId, paths }, {
|
||||
jobId: `cleanup-flyer-${flyerId}`,
|
||||
removeOnComplete: true,
|
||||
// Provide default successful mock implementations for dependencies
|
||||
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', master_item_id: 1 }],
|
||||
});
|
||||
logger.info(`[Worker] Enqueued cleanup job for flyer ${flyerId}.`);
|
||||
}
|
||||
vi.mocked(createFlyerAndItems).mockResolvedValue({
|
||||
flyer: { flyer_id: 1, file_name: 'test.jpg', image_url: 'test.jpg', item_count: 1, created_at: new Date().toISOString() } as any,
|
||||
items: [],
|
||||
});
|
||||
mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
|
||||
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
|
||||
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
|
||||
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
|
||||
return {
|
||||
id: 'job-1',
|
||||
data: {
|
||||
filePath: '/tmp/flyer.jpg',
|
||||
originalFileName: 'flyer.jpg',
|
||||
checksum: 'checksum-123',
|
||||
...data,
|
||||
},
|
||||
updateProgress: vi.fn(),
|
||||
opts: { attempts: 3 },
|
||||
attemptsMade: 1,
|
||||
} as unknown as Job<FlyerJobData>;
|
||||
};
|
||||
|
||||
async processJob(job: Job<FlyerJobData>) {
|
||||
const { filePath, originalFileName } = job.data;
|
||||
const createdImagePaths: string[] = [];
|
||||
let newFlyerId: number | undefined;
|
||||
describe('processJob (Orchestrator)', () => {
|
||||
it('should process an image file successfully and enqueue a cleanup job', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
|
||||
|
||||
logger.info(`[Worker] Picked up job ${job.id} for file: ${originalFileName} (Checksum: ${job.data.checksum})`);
|
||||
const result = await service.processJob(job);
|
||||
|
||||
try {
|
||||
await job.updateProgress({ message: 'Starting process...' });
|
||||
const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(filePath, job);
|
||||
createdImagePaths.push(...tempImagePaths);
|
||||
expect(result).toEqual({ flyerId: 1 });
|
||||
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
|
||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
await job.updateProgress({ message: 'Extracting data...' });
|
||||
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data);
|
||||
it('should convert a PDF, process its images, and enqueue a cleanup job for all files', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf' });
|
||||
|
||||
await job.updateProgress({ message: 'Saving to database...' });
|
||||
const newFlyer = await this._saveProcessedFlyerData(extractedData, imagePaths, job.data);
|
||||
// Mock readdir to return Dirent-like objects for the converted files
|
||||
mocks.readdir.mockResolvedValue([
|
||||
{ name: 'flyer-1.jpg' },
|
||||
{ name: 'flyer-2.jpg' },
|
||||
] as Dirent[]);
|
||||
|
||||
newFlyerId = newFlyer.flyer_id;
|
||||
logger.info(`[Worker] Job ${job.id} for ${originalFileName} processed successfully. Flyer ID: ${newFlyerId}`);
|
||||
return { flyerId: newFlyer.flyer_id };
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = 'An unknown error occurred';
|
||||
if (error instanceof PdfConversionError) {
|
||||
errorMessage = error.message;
|
||||
logger.error(`[Worker] PDF Conversion failed for job ${job.id}.`, {
|
||||
error: errorMessage,
|
||||
stderr: error.stderr,
|
||||
jobData: job.data,
|
||||
});
|
||||
} else if (error instanceof AiDataValidationError) {
|
||||
errorMessage = error.message;
|
||||
logger.error(`[Worker] AI Data Validation failed for job ${job.id}.`, {
|
||||
error: errorMessage,
|
||||
validationErrors: error.validationErrors,
|
||||
rawData: error.rawData,
|
||||
jobData: job.data,
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
logger.error(`[Worker] A generic error occurred in job ${job.id}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
|
||||
error: errorMessage, stack: error.stack, jobData: job.data,
|
||||
});
|
||||
}
|
||||
await job.updateProgress({ message: `Error: ${errorMessage}` });
|
||||
throw error;
|
||||
} finally {
|
||||
if (newFlyerId) {
|
||||
const pathsToClean = [filePath, ...createdImagePaths];
|
||||
await this._enqueueCleanup(newFlyerId, pathsToClean);
|
||||
} else {
|
||||
logger.warn(`[Worker] Job ${job.id} for ${originalFileName} failed. Temporary files will NOT be cleaned up to allow for manual inspection.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await service.processJob(job);
|
||||
|
||||
// Verify that pdftocairo was called
|
||||
expect(mocks.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pdftocairo -jpeg -r 150')
|
||||
);
|
||||
// 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),
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||
// Verify cleanup job includes original PDF and both generated images
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
{ flyerId: 1, paths: ['/tmp/flyer.pdf', expect.stringContaining('flyer-1.jpg'), expect.stringContaining('flyer-2.jpg')] },
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
|
||||
const job = createMockJob({});
|
||||
const aiError = new Error('AI model exploded');
|
||||
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the database service fails', async () => {
|
||||
const job = createMockJob({});
|
||||
const dbError = new Error('Database transaction failed');
|
||||
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' });
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
|
||||
const job = createMockJob({});
|
||||
vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
|
||||
await expect(service.processJob(job)).rejects.toThrow();
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_convertPdfToImages (private method)', () => {
|
||||
it('should call pdftocairo and return sorted image paths on success', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/test.pdf' });
|
||||
// Mock readdir to return unsorted Dirent-like objects
|
||||
mocks.readdir.mockResolvedValue([
|
||||
{ name: 'test-10.jpg' },
|
||||
{ name: 'test-1.jpg' },
|
||||
{ name: 'test-2.jpg' },
|
||||
{ name: 'other-file.txt' },
|
||||
] as Dirent[]);
|
||||
|
||||
// Access and call the private method for testing
|
||||
const imagePaths = await (service as any)._convertPdfToImages('/tmp/test.pdf', job);
|
||||
|
||||
expect(mocks.execAsync).toHaveBeenCalledWith(
|
||||
'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"'
|
||||
);
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' });
|
||||
// Verify that the paths are correctly sorted numerically
|
||||
expect(imagePaths).toEqual([
|
||||
'/tmp/test-1.jpg',
|
||||
'/tmp/test-2.jpg',
|
||||
'/tmp/test-10.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw PdfConversionError if no images are generated', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/empty.pdf' });
|
||||
// Mock readdir to return no matching files
|
||||
mocks.readdir.mockResolvedValue([]);
|
||||
|
||||
await expect((service as any)._convertPdfToImages('/tmp/empty.pdf', job))
|
||||
.rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf');
|
||||
});
|
||||
|
||||
it('should re-throw an error if the exec command fails', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/bad.pdf' });
|
||||
const commandError = new Error('pdftocairo not found');
|
||||
mocks.execAsync.mockRejectedValue(commandError);
|
||||
|
||||
await expect((service as any)._convertPdfToImages('/tmp/bad.pdf', job))
|
||||
.rejects.toThrow(commandError);
|
||||
});
|
||||
});
|
||||
});
|
||||
8
src/services/flyerProcessingService.types.ts
Normal file
8
src/services/flyerProcessingService.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface FlyerJobData {
|
||||
filePath: string;
|
||||
originalFileName: string;
|
||||
checksum: string;
|
||||
userId?: string;
|
||||
submitterIp?: string;
|
||||
userProfileAddress?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user