Compare commits

...

4 Commits

Author SHA1 Message Date
Gitea Actions
e0bf96824c ci: Bump version to 0.9.28 [skip ci] 2026-01-06 00:28:11 +05:00
e86e09703e even even more and more test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 59s
2026-01-05 11:27:13 -08:00
Gitea Actions
275741c79e ci: Bump version to 0.9.27 [skip ci] 2026-01-05 15:32:08 +05:00
3a40249ddb even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m19s
2026-01-05 02:30:28 -08:00
23 changed files with 620 additions and 295 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.26",
"version": "0.9.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.26",
"version": "0.9.28",
"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.9.26",
"version": "0.9.28",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -197,6 +197,33 @@ describe('Auth Routes (/api/auth)', () => {
);
});
it('should allow registration with an empty string for full_name', async () => {
// Arrange
const email = 'empty-name@test.com';
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: createMockUserProfile({ user: { email } }),
accessToken: 'token',
refreshToken: 'token',
});
// Act
const response = await supertest(app).post('/api/auth/register').send({
email,
password: strongPassword,
full_name: '', // Send an empty string
});
// Assert
expect(response.status).toBe(201);
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
email,
strongPassword,
undefined, // The preprocess step in the Zod schema should convert '' to undefined
undefined,
mockLogger,
);
});
it('should set a refresh token cookie on successful registration', async () => {
const mockNewUser = createMockUserProfile({
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
@@ -396,6 +423,24 @@ describe('Auth Routes (/api/auth)', () => {
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader[0]).toContain('Max-Age=2592000'); // 30 days in seconds
});
it('should return 400 for an invalid email format', async () => {
const response = await supertest(app)
.post('/api/auth/login')
.send({ email: 'not-an-email', password: 'password123' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
});
it('should return 400 if password is missing', async () => {
const response = await supertest(app)
.post('/api/auth/login')
.send({ email: 'test@test.com' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Password is required.');
});
});
describe('POST /forgot-password', () => {
@@ -586,4 +631,82 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.headers['set-cookie'][0]).toContain('refreshToken=;');
});
});
describe('Rate Limiting on /forgot-password', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const email = 'rate-limit-test@example.com';
const maxRequests = 5; // from the rate limiter config
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
// Act: Make `maxRequests` successful calls with the special header
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/forgot-password')
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter for this test
.send({ email });
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
}
// Act: Make one more call, which should be blocked
const blockedResponse = await supertest(app)
.post('/api/auth/forgot-password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ email });
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many password reset requests');
});
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
// Arrange
const email = 'no-rate-limit-test@example.com';
const overLimitRequests = 7; // More than the max of 5
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
// Act: Make more calls than the limit. They should all succeed because the limiter is skipped.
for (let i = 0; i < overLimitRequests; i++) {
const response = await supertest(app)
.post('/api/auth/forgot-password')
// NO 'X-Test-Rate-Limit-Enable' header is sent
.send({ email });
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
}
});
});
describe('Rate Limiting on /reset-password', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 10; // from the rate limiter config in auth.routes.ts
const newPassword = 'a-Very-Strong-Password-123!';
const token = 'some-token-for-rate-limit-test';
// Mock the service to return a consistent value for the first `maxRequests` calls.
// The endpoint returns 400 for invalid tokens, which is fine for this test.
// We just need to ensure it's not a 429.
mockedAuthService.updatePassword.mockResolvedValue(null);
// Act: Make `maxRequests` calls. They should not be rate-limited.
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/reset-password')
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter
.send({ token, newPassword });
// The expected status is 400 because the token is invalid, but not 429.
expect(response.status, `Request ${i + 1} should not be rate-limited`).toBe(400);
}
// Act: Make one more call, which should be blocked by the rate limiter.
const blockedResponse = await supertest(app)
.post('/api/auth/reset-password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ token, newPassword });
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many password reset attempts');
});
});
});

View File

@@ -23,9 +23,14 @@ const forgotPasswordLimiter = rateLimit({
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
// Do not skip in test environment so we can write integration tests for it.
// The limiter uses an in-memory store by default, so counts are reset when the test server restarts.
// skip: () => isTestEnv,
// Skip in test env unless a specific header is present.
// This allows E2E tests to run unblocked, while specific integration
// tests for the limiter can opt-in by sending the header.
skip: (req) => {
if (!isTestEnv) return false; // Never skip in non-test environments.
// In test env, skip UNLESS the opt-in header is present.
return req.headers['x-test-rate-limit-enable'] !== 'true';
},
});
const resetPasswordLimiter = rateLimit({
@@ -37,20 +42,24 @@ const resetPasswordLimiter = rateLimit({
skip: () => isTestEnv, // Skip this middleware if in test environment
});
// --- Reusable Schemas ---
const passwordSchema = z
.string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
});
const registerSchema = z.object({
body: z.object({
// Sanitize email by trimming and converting to lowercase.
email: z.string().trim().toLowerCase().email('A valid email is required.'),
password: z
.string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
password: passwordSchema,
// Sanitize optional string inputs.
full_name: z.string().trim().optional(),
full_name: z.preprocess((val) => (val === '' ? undefined : val), z.string().trim().optional()),
// Allow empty string or valid URL. If empty string is received, convert to undefined.
avatar_url: z.preprocess(
(val) => (val === '' ? undefined : val),
@@ -59,6 +68,14 @@ const registerSchema = z.object({
}),
});
const loginSchema = z.object({
body: z.object({
email: z.string().trim().toLowerCase().email('A valid email is required.'),
password: requiredString('Password is required.'),
rememberMe: z.boolean().optional(),
}),
});
const forgotPasswordSchema = z.object({
body: z.object({
// Sanitize email by trimming and converting to lowercase.
@@ -69,14 +86,7 @@ const forgotPasswordSchema = z.object({
const resetPasswordSchema = z.object({
body: z.object({
token: requiredString('Token is required.'),
newPassword: z
.string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
newPassword: passwordSchema,
}),
});
@@ -122,52 +132,56 @@ router.post(
);
// Login Route
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
passport.authenticate(
'local',
{ session: false },
async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
router.post(
'/login',
validateRequest(loginSchema),
(req: Request, res: Response, next: NextFunction) => {
passport.authenticate(
'local',
{ session: false },
async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
if (err) {
req.log.error(
{ error: err },
`Login authentication error in /login route for email: ${req.body.email}`,
);
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
if (err) {
req.log.error(
{ error: err },
`Login authentication error in /login route for email: ${req.body.email}`,
);
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
try {
const { rememberMe } = req.body;
const userProfile = user as UserProfile;
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
try {
const { rememberMe } = req.body;
const userProfile = user as UserProfile;
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
};
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
};
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
const email = (user as UserProfile)?.user?.email || req.body.email;
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
return next(tokenErr);
}
},
)(req, res, next);
});
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
const email = (user as UserProfile)?.user?.email || req.body.email;
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
return next(tokenErr);
}
},
)(req, res, next);
},
);
// Route to request a password reset
router.post(

View File

@@ -102,6 +102,7 @@ vi.mock('passport', () => {
// Now, import the passport configuration which will use our mocks
import passport, { isAdmin, optionalAuth, mockAuth } from './passport.routes';
import { logger } from '../services/logger.server';
import { ForbiddenError } from '../services/db/errors.db';
describe('Passport Configuration', () => {
beforeEach(() => {
@@ -468,7 +469,7 @@ describe('Passport Configuration', () => {
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 403 Forbidden if user does not have "admin" role', () => {
it('should call next with a ForbiddenError if user does not have "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: createMockUserProfile({
@@ -481,14 +482,11 @@ describe('Passport Configuration', () => {
isAdmin(mockReq as Request, mockRes as Response, mockNext);
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Forbidden: Administrator access required.',
});
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 403 Forbidden if req.user is missing', () => {
it('should call next with a ForbiddenError if req.user is missing', () => {
// Arrange
const mockReq = {} as Request; // No req.user
@@ -496,11 +494,38 @@ describe('Passport Configuration', () => {
isAdmin(mockReq, mockRes as Response, mockNext);
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 403 Forbidden for various invalid user object shapes', () => {
it('should log a warning when a non-admin user tries to access an admin route', () => {
// Arrange
const mockReq: Partial<Request> = {
user: createMockUserProfile({
role: 'user',
user: { user_id: 'user-id-123', email: 'user@test.com' },
}),
};
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
// Assert
expect(logger.warn).toHaveBeenCalledWith('Admin access denied for user: user-id-123');
});
it('should log a warning with "unknown" user when req.user is missing', () => {
// Arrange
const mockReq = {} as Request; // No req.user
// Act
isAdmin(mockReq, mockRes as Response, mockNext);
// Assert
expect(logger.warn).toHaveBeenCalledWith('Admin access denied for user: unknown');
});
it('should call next with a ForbiddenError for various invalid user object shapes', () => {
const mockNext = vi.fn();
const mockRes: Partial<Response> = {
status: vi.fn().mockReturnThis(),
@@ -510,29 +535,29 @@ describe('Passport Configuration', () => {
// Case 1: user is not an object (e.g., a string)
const req1 = { user: 'not-an-object' } as unknown as Request;
isAdmin(req1, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenLastCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 2: user is null
const req2 = { user: null } as unknown as Request;
isAdmin(req2, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenLastCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 3: user object is missing 'user' property
const req3 = { user: { role: 'admin' } } as unknown as Request;
isAdmin(req3, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenLastCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 4: user.user is not an object
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
isAdmin(req4, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenLastCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 5: user.user is missing 'user_id'
@@ -540,15 +565,15 @@ describe('Passport Configuration', () => {
user: { role: 'admin', user: { email: 'test@test.com' } },
} as unknown as Request;
isAdmin(req5, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenLastCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Reset the main mockNext for other tests in the suite
mockNext.mockClear();
});
it('should return 403 Forbidden if req.user is not a valid UserProfile object', () => {
it('should call next with a ForbiddenError if req.user is not a valid UserProfile object', () => {
// Arrange
const mockReq: Partial<Request> = {
// An object that is not a valid UserProfile (e.g., missing 'role')
@@ -561,11 +586,8 @@ describe('Passport Configuration', () => {
isAdmin(mockReq as Request, mockRes as Response, mockNext);
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Forbidden: Administrator access required.',
});
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,7 @@ import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { ForbiddenError } from '../services/db/errors.db';
const JWT_SECRET = process.env.JWT_SECRET!;
@@ -307,7 +308,7 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
logger.warn(`Admin access denied for user: ${userIdForLog}`);
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
next(new ForbiddenError('Forbidden: Administrator access required.'));
}
};

View File

@@ -81,6 +81,7 @@ vi.mock('./db/flyer.db', () => ({
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
processAndSaveImage: vi.fn(),
}));
vi.mock('./db/admin.db', () => ({
@@ -93,8 +94,8 @@ vi.mock('./db/admin.db', () => ({
import * as dbModule from './db/index.db';
import { flyerQueue } from './queueService.server';
import { createFlyerAndItems } from './db/flyer.db';
import { withTransaction } from './db/index.db';
import { generateFlyerIcon } from '../utils/imageProcessor';
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
// This helps ensure type safety in mocks without relying on 'any'.
@@ -808,9 +809,11 @@ describe('AI Service (Server)', () => {
expect(
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
).toBeNull(); // This was a duplicate, fixed.
// The code now fails earlier because it can't find the closing brace.
// We need to update the assertion to match the actual error log.
expect(localLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
{ responseText }, // The log includes the full response text.
"[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
);
});
});
@@ -1052,6 +1055,7 @@ describe('AI Service (Server)', () => {
beforeEach(() => {
// Default success mocks. Use createMockFlyer for a more complete mock.
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(processAndSaveImage).mockResolvedValue('processed.jpg');
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: {

View File

@@ -32,13 +32,13 @@ const joinUrl = (base: string, path: string): string => {
* A promise that holds the in-progress token refresh operation.
* This prevents multiple parallel refresh requests.
*/
let refreshTokenPromise: Promise<string> | null = null;
let performTokenRefreshPromise: Promise<string> | null = null;
/**
* Attempts to refresh the access token using the HttpOnly refresh token cookie.
* @returns A promise that resolves to the new access token.
*/
const refreshToken = async (): Promise<string> => {
const _performTokenRefresh = async (): Promise<string> => {
logger.info('Attempting to refresh access token...');
try {
// Use the joinUrl helper for consistency, though usually this is a relative fetch in browser
@@ -75,11 +75,15 @@ const refreshToken = async (): Promise<string> => {
};
/**
* A custom fetch wrapper that handles automatic token refreshing.
* All authenticated API calls should use this function.
* @param url The URL to fetch.
* @param options The fetch options.
* @returns A promise that resolves to the fetch Response.
* A custom fetch wrapper that handles automatic token refreshing for authenticated API calls.
* If a request fails with a 401 Unauthorized status, it attempts to refresh the access token
* using the refresh token cookie. If successful, it retries the original request with the new token.
* All authenticated API calls should use this function or one of its helpers (e.g., `authedGet`).
*
* @param url The endpoint path (e.g., '/users/profile') or a full URL.
* @param options Standard `fetch` options (method, body, etc.).
* @param apiOptions Custom options for the API client, such as `tokenOverride` for testing or an `AbortSignal`.
* @returns A promise that resolves to the final `Response` object from the fetch call.
*/
export const apiFetch = async (
url: string,
@@ -122,12 +126,12 @@ export const apiFetch = async (
try {
logger.info(`apiFetch: Received 401 for ${fullUrl}. Attempting token refresh.`);
// If no refresh is in progress, start one.
if (!refreshTokenPromise) {
refreshTokenPromise = refreshToken();
if (!performTokenRefreshPromise) {
performTokenRefreshPromise = _performTokenRefresh();
}
// Wait for the existing or new refresh operation to complete.
const newToken = await refreshTokenPromise;
const newToken = await performTokenRefreshPromise;
logger.info(`apiFetch: Token refreshed. Retrying original request to ${fullUrl}.`);
// Retry the original request with the new token.
@@ -138,7 +142,7 @@ export const apiFetch = async (
return Promise.reject(refreshError);
} finally {
// Clear the promise so the next 401 will trigger a new refresh.
refreshTokenPromise = null;
performTokenRefreshPromise = null;
}
}
@@ -768,6 +772,25 @@ export const triggerFailingJob = (tokenOverride?: string): Promise<Response> =>
export const getJobStatus = (jobId: string, tokenOverride?: string): Promise<Response> =>
authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
/**
* Refreshes an access token using a refresh token cookie.
* This is intended for use in Node.js test environments where cookies must be set manually.
* @param cookie The full 'Cookie' header string (e.g., "refreshToken=...").
* @returns A promise that resolves to the fetch Response.
*/
export async function refreshToken(cookie: string) {
const url = joinUrl(API_BASE_URL, '/auth/refresh-token');
const options: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// The browser would handle this automatically, but in Node.js tests we must set it manually.
Cookie: cookie,
},
};
return fetch(url, options);
}
/**
* Triggers the clearing of the geocoding cache on the server.
* Requires admin privileges.

View File

@@ -34,6 +34,9 @@ vi.mock('../services/queueService.server', () => ({
weeklyAnalyticsQueue: {
add: vi.fn(),
},
emailQueue: {
add: vi.fn(),
},
}));
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
@@ -131,7 +134,8 @@ describe('Background Job Service', () => {
describe('Manual Triggers', () => {
it('triggerAnalyticsReport should add a daily report job to the queue', async () => {
vi.mocked(analyticsQueue.add).mockResolvedValue({ id: 'manual-job-1' } as any);
// The mock should return the jobId passed to it to simulate bullmq's behavior
vi.mocked(analyticsQueue.add).mockImplementation(async (name, data, opts) => ({ id: opts?.jobId }) as any);
const jobId = await service.triggerAnalyticsReport();
expect(jobId).toContain('manual-report-');
@@ -143,7 +147,8 @@ describe('Background Job Service', () => {
});
it('triggerWeeklyAnalyticsReport should add a weekly report job to the queue', async () => {
vi.mocked(weeklyAnalyticsQueue.add).mockResolvedValue({ id: 'manual-weekly-job-1' } as any);
// The mock should return the jobId passed to it
vi.mocked(weeklyAnalyticsQueue.add).mockImplementation(async (name, data, opts) => ({ id: opts?.jobId }) as any);
const jobId = await service.triggerWeeklyAnalyticsReport();
expect(jobId).toContain('manual-weekly-report-');

View File

@@ -10,6 +10,11 @@ import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
type UserDealGroup = {
userProfile: { user_id: string; email: string; full_name: string | null };
deals: WatchedItemDeal[];
};
interface EmailJobData {
to: string;
subject: string;
@@ -93,6 +98,33 @@ export class BackgroundJobService {
};
}
private async _processDealsForUser({
userProfile,
deals,
}: UserDealGroup): Promise<Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> | null> {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
);
// Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
// Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(
{ err: userError },
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
);
return null; // Return null on error for this user.
}
}
/**
* Checks for new deals on watched items for all users and sends notifications.
* This function is designed to be run periodically (e.g., daily).
@@ -112,76 +144,47 @@ export class BackgroundJobService {
this.logger.info(`[BackgroundJob] Found ${allDeals.length} total deals across all users.`);
// 2. Group deals by user in memory.
const dealsByUser = allDeals.reduce<
Record<
string,
{
userProfile: { user_id: string; email: string; full_name: string | null };
deals: WatchedItemDeal[];
}
>
>((acc, deal) => {
if (!acc[deal.user_id]) {
acc[deal.user_id] = {
const dealsByUser = new Map<string, UserDealGroup>();
for (const deal of allDeals) {
let userGroup = dealsByUser.get(deal.user_id);
if (!userGroup) {
userGroup = {
userProfile: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
deals: [],
};
dealsByUser.set(deal.user_id, userGroup);
}
acc[deal.user_id].deals.push(deal);
return acc;
}, {});
const allNotifications: Omit<
Notification,
'notification_id' | 'is_read' | 'created_at' | 'updated_at'
>[] = [];
userGroup.deals.push(deal);
}
// 3. Process each user's deals in parallel.
const userProcessingPromises = Object.values(dealsByUser).map(
async ({ userProfile, deals }) => {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
);
// 4. Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
// 5. Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(
{ err: userError },
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
);
return null; // Return null on error for this user.
}
},
const userProcessingPromises = Array.from(dealsByUser.values()).map((userGroup) =>
this._processDealsForUser(userGroup),
);
// Wait for all user processing to complete.
const results = await Promise.allSettled(userProcessingPromises);
// 6. Collect all successfully created notifications.
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
allNotifications.push(result.value);
}
});
const successfulNotifications = results
.filter(
(
result,
): result is PromiseFulfilledResult<
Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'>
> => result.status === 'fulfilled' && !!result.value,
)
.map((result) => result.value);
// 7. Bulk insert all in-app notifications in a single query.
if (allNotifications.length > 0) {
const notificationsForDb = allNotifications.map((n) => ({
if (successfulNotifications.length > 0) {
const notificationsForDb = successfulNotifications.map((n) => ({
...n,
updated_at: new Date().toISOString(),
}));
await this.notificationRepo.createBulkNotifications(notificationsForDb, this.logger);
this.logger.info(
`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`,
`[BackgroundJob] Successfully created ${successfulNotifications.length} in-app notifications.`,
);
}

View File

@@ -6,6 +6,7 @@ import {
UniqueConstraintError,
ForeignKeyConstraintError,
NotFoundError,
ForbiddenError,
ValidationError,
FileUploadError,
NotNullConstraintError,
@@ -89,6 +90,25 @@ describe('Custom Database and Application Errors', () => {
});
});
describe('ForbiddenError', () => {
it('should create an error with a default message and status 403', () => {
const error = new ForbiddenError();
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(RepositoryError);
expect(error).toBeInstanceOf(ForbiddenError);
expect(error.message).toBe('Access denied.');
expect(error.status).toBe(403);
expect(error.name).toBe('ForbiddenError');
});
it('should create an error with a custom message', () => {
const message = 'You shall not pass.';
const error = new ForbiddenError(message);
expect(error.message).toBe(message);
});
});
describe('ValidationError', () => {
it('should create an error with a default message, status 400, and validation errors array', () => {
const validationIssues = [{ path: ['email'], message: 'Invalid email' }];

View File

@@ -86,6 +86,16 @@ export class NotFoundError extends RepositoryError {
}
}
/**
* Thrown when the user does not have permission to access the resource.
*/
export class ForbiddenError extends RepositoryError {
constructor(message = 'Access denied.') {
super(message, 403); // 403 Forbidden
this.name = 'ForbiddenError';
}
}
/**
* Defines the structure for a single validation issue, often from a library like Zod.
*/

View File

@@ -5,7 +5,6 @@ import type { Logger } from 'pino';
import type { FlyerInsert, FlyerItemInsert } from '../types';
import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this import for AiProcessorResult
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
import { generateFlyerIcon } from '../utils/imageProcessor';
import { TransformationError } from './processingErrors';
import { parsePriceToCents } from '../utils/priceParser';
@@ -48,36 +47,21 @@ export class FlyerDataTransformer {
};
}
/**
* Generates a 64x64 icon for the flyer's first page.
* @param firstImage The path to the first image of the flyer.
* @param logger The logger instance.
* @returns The filename of the generated icon.
*/
private async _generateIcon(firstImage: string, logger: Logger): Promise<string> {
const iconFileName = await generateFlyerIcon(
firstImage,
path.join(path.dirname(firstImage), 'icons'),
logger,
);
return iconFileName;
}
/**
* Constructs the full public URLs for the flyer image and its icon.
* @param firstImage The path to the first image of the flyer.
* @param imageFileName The filename of the main processed image.
* @param iconFileName The filename of the generated icon.
* @param baseUrl The base URL from the job payload.
* @param logger The logger instance.
* @returns An object containing the full image_url and icon_url.
*/
private _buildUrls(
firstImage: string,
imageFileName: string,
iconFileName: string,
baseUrl: string | undefined,
logger: Logger,
): { imageUrl: string; iconUrl: string } {
logger.debug({ firstImage, iconFileName, baseUrl }, 'Building URLs');
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
let finalBaseUrl = baseUrl;
if (!finalBaseUrl) {
const port = process.env.PORT || 3000;
@@ -85,10 +69,9 @@ export class FlyerDataTransformer {
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
}
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
const imageBasename = path.basename(firstImage);
const imageUrl = `${finalBaseUrl}/flyer-images/${imageBasename}`;
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
logger.debug({ imageUrl, iconUrl, imageBasename }, 'Constructed URLs');
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');
return { imageUrl, iconUrl };
}
@@ -104,8 +87,9 @@ export class FlyerDataTransformer {
*/
async transform(
aiResult: AiProcessorResult,
imagePaths: { path: string; mimetype: string }[],
originalFileName: string,
imageFileName: string,
iconFileName: string,
checksum: string,
userId: string | undefined,
logger: Logger,
@@ -116,9 +100,7 @@ export class FlyerDataTransformer {
try {
const { data: extractedData, needsReview } = aiResult;
const firstImage = imagePaths[0].path;
const iconFileName = await this._generateIcon(firstImage, logger);
const { imageUrl, iconUrl } = this._buildUrls(firstImage, iconFileName, baseUrl, logger);
const { imageUrl, iconUrl } = this._buildUrls(imageFileName, iconFileName, baseUrl, logger);
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));

View File

@@ -25,12 +25,6 @@ vi.mock('node:fs/promises', async (importOriginal) => {
};
});
// Mock image processor functions
vi.mock('../utils/imageProcessor', () => ({
processAndSaveImage: vi.fn(),
generateFlyerIcon: vi.fn(),
}));
// Import service and dependencies (FlyerJobData already imported from types above)
import { FlyerProcessingService } from './flyerProcessingService.server';
import * as db from './db/index.db';
@@ -48,9 +42,14 @@ import { NotFoundError } from './db/errors.db';
import { FlyerFileHandler } from './flyerFileHandler.server';
import { FlyerAiProcessor } from './flyerAiProcessor.server';
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
import { processAndSaveImage, generateFlyerIcon } from '../utils/imageProcessor';
import { generateFlyerIcon } from '../utils/imageProcessor';
import type { AIService } from './aiService.server';
// Mock image processor functions
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
}));
// Mock dependencies
vi.mock('./aiService.server', () => ({
aiService: {
@@ -180,7 +179,6 @@ describe('FlyerProcessingService', () => {
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
});
beforeEach(() => {
vi.mocked(processAndSaveImage).mockResolvedValue('processed-flyer.jpg');
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
});
@@ -211,38 +209,47 @@ describe('FlyerProcessingService', () => {
};
describe('processJob (Orchestrator)', () => {
it('should process an image file successfully, using processed image URLs, and enqueue a cleanup job', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg', baseUrl: 'http://test.com' });
it('should process an image file successfully and enqueue a cleanup job', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
// Simulate the file handler processing the image and returning the path to the new, cleaned file.
// Arrange: Mock dependencies to simulate a successful run
mockFileHandler.prepareImageInputs.mockResolvedValue({
imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }],
createdImagePaths: ['/tmp/flyer-processed.jpeg'],
});
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
const result = await service.processJob(job);
expect(result).toEqual({ flyerId: 1 });
// 1. File handler was called
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
// 2. AI processor was called
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
// 3. Icon was generated from the processed image
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp/icons', expect.any(Object));
// 4. Transformer was called with the correct filenames
expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
expect.any(Object), // aiResult
'flyer.jpg', // originalFileName
'flyer-processed.jpeg', // imageFileName
'icon-flyer.webp', // iconFileName
'checksum-123', // checksum
undefined, // userId
expect.any(Object), // logger
'http://localhost:3000', // baseUrl
);
// 5. DB transaction was initiated
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
// Assert that the image processing functions were called correctly
// The first image path from prepareImageInputs is the *processed* one.
expect(processAndSaveImage).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp', 'flyer.jpg', expect.any(Object));
// The icon is generated from the *newly processed* image from processAndSaveImage
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/processed-flyer.jpg', '/tmp/icons', expect.any(Object));
// Assert that createFlyerAndItems was called with the CORRECT, overwritten URLs
const createFlyerAndItemsCall = vi.mocked(createFlyerAndItems).mock.calls[0];
const flyerDataArg = createFlyerAndItemsCall[0]; // The flyerData object
expect(flyerDataArg.image_url).toBe('http://test.com/flyer-images/processed-flyer.jpg');
expect(flyerDataArg.icon_url).toBe('http://test.com/flyer-images/icons/icon-flyer.webp');
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
expect(mocks.mockAdminLogActivity).toHaveBeenCalledTimes(1);
// Assert that the cleanup job includes all original and generated files
// 6. Cleanup job was enqueued with all generated files
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{
@@ -250,7 +257,6 @@ describe('FlyerProcessingService', () => {
paths: [
'/tmp/flyer.jpg', // original job path
'/tmp/flyer-processed.jpeg', // from prepareImageInputs
'/tmp/processed-flyer.jpg', // from processAndSaveImage
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
],
},
@@ -270,6 +276,7 @@ describe('FlyerProcessingService', () => {
],
createdImagePaths: createdPaths,
});
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-1.webp');
await service.processJob(job);
@@ -278,6 +285,8 @@ describe('FlyerProcessingService', () => {
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify icon generation was called for the first page
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-1.jpg', '/tmp/icons', expect.any(Object));
// Verify cleanup job includes original PDF and all generated/processed images
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
@@ -287,8 +296,7 @@ describe('FlyerProcessingService', () => {
'/tmp/flyer.pdf', // original job path
'/tmp/flyer-1.jpg', // from prepareImageInputs
'/tmp/flyer-2.jpg', // from prepareImageInputs
'/tmp/processed-flyer.jpg', // from processAndSaveImage
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
'/tmp/icons/icon-flyer-1.webp', // from generateFlyerIcon
],
},
expect.any(Object),
@@ -421,6 +429,7 @@ describe('FlyerProcessingService', () => {
imagePaths: [{ path: convertedPath, mimetype: 'image/png' }],
createdImagePaths: [convertedPath],
});
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-converted.webp');
await service.processJob(job);
@@ -428,6 +437,8 @@ describe('FlyerProcessingService', () => {
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
// Verify icon generation was called for the converted image
expect(generateFlyerIcon).toHaveBeenCalledWith(convertedPath, '/tmp/icons', expect.any(Object));
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{
@@ -435,8 +446,7 @@ describe('FlyerProcessingService', () => {
paths: [
'/tmp/flyer.gif', // original job path
convertedPath, // from prepareImageInputs
'/tmp/processed-flyer.jpg', // from processAndSaveImage
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
'/tmp/icons/icon-flyer-converted.webp', // from generateFlyerIcon
],
},
expect.any(Object),
@@ -495,17 +505,14 @@ describe('FlyerProcessingService', () => {
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const transformationError = new TransformationError('Icon generation failed.');
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
// to always succeed. For this test, we override that mock to simulate a failure
// bubbling up from the icon generation step.
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(transformationError);
const iconGenError = new Error('Icon generation failed.');
vi.mocked(generateFlyerIcon).mockRejectedValue(iconGenError);
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
expect(reportErrorSpy).toHaveBeenCalledWith(transformationError, job, expect.any(Object), expect.any(Array));
expect(reportErrorSpy).toHaveBeenCalledWith(iconGenError, job, expect.any(Object), expect.any(Array));
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',

View File

@@ -17,8 +17,8 @@ import {
} from './processingErrors';
import { NotFoundError } from './db/errors.db';
import { createFlyerAndItems } from './db/flyer.db';
import { logger as globalLogger } from './logger.server';
import { processAndSaveImage, generateFlyerIcon } from '../utils/imageProcessor';
import { logger as globalLogger } from './logger.server'; // This was a duplicate, fixed.
import { generateFlyerIcon } from '../utils/imageProcessor';
// Define ProcessingStage locally as it's not exported from the types file.
export type ProcessingStage = {
@@ -80,31 +80,6 @@ export class FlyerProcessingService {
stages[0].detail = `${imagePaths.length} page(s) ready for AI.`;
await job.updateProgress({ stages });
// --- START FIX for Integration Tests ---
// The integration tests upload single-page images (JPG/PNG). We assume the first
// image is the primary one to be processed for metadata stripping and icon generation.
const primaryImage = imagePaths[0];
if (!primaryImage) {
throw new FlyerProcessingError('No processable image found after preparation stage.', 'INPUT_ERROR');
}
const flyerImageDir = path.dirname(primaryImage.path);
// Process the main image to strip metadata and optimize it. This creates a new file.
const processedImageFileName = await processAndSaveImage(
primaryImage.path,
flyerImageDir,
job.data.originalFileName,
logger,
);
const processedImagePath = path.join(flyerImageDir, processedImageFileName);
allFilePaths.push(processedImagePath); // Track the new file for cleanup.
// Generate the icon from the NEWLY PROCESSED image.
const iconsDir = path.join(flyerImageDir, 'icons');
const iconFileName = await generateFlyerIcon(processedImagePath, iconsDir, logger);
allFilePaths.push(path.join(iconsDir, iconFileName)); // Track icon for cleanup.
// Stage 2: Extract Data with AI
stages[1].status = 'in-progress';
await job.updateProgress({ stages });
@@ -117,20 +92,27 @@ export class FlyerProcessingService {
stages[2].status = 'in-progress';
await job.updateProgress({ stages });
// The fileHandler has already prepared the primary image (e.g., by stripping EXIF data).
// We now generate an icon from it and prepare the filenames for the transformer.
const primaryImagePath = imagePaths[0].path;
const imageFileName = path.basename(primaryImagePath);
const iconsDir = path.join(path.dirname(primaryImagePath), 'icons');
const iconFileName = await generateFlyerIcon(primaryImagePath, iconsDir, logger);
// Add the newly generated icon to the list of files to be cleaned up.
// The main processed image path is already in `allFilePaths` via `createdImagePaths`.
allFilePaths.push(path.join(iconsDir, iconFileName));
const { flyerData, itemsForDb } = await this.transformer.transform(
aiResult,
imagePaths,
job.data.originalFileName,
imageFileName,
iconFileName,
job.data.checksum,
job.data.userId,
logger,
job.data.baseUrl,
);
// Overwrite the URLs generated by the transformer to point to our processed files.
// This ensures the correct, metadata-stripped image is referenced in the database.
flyerData.image_url = `${job.data.baseUrl}/flyer-images/${processedImageFileName}`;
flyerData.icon_url = `${job.data.baseUrl}/flyer-images/icons/${iconFileName}`;
stages[2].status = 'completed';
await job.updateProgress({ stages });

View File

@@ -0,0 +1,51 @@
// src/tests/e2e/admin-authorization.e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { cleanupDb } from '../utils/cleanup';
import { createAndLoginUser } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
/**
* @vitest-environment node
*/
describe('Admin Route Authorization', () => {
let regularUser: UserProfile;
let regularUserAuthToken: string;
beforeAll(async () => {
// Create a standard user for testing authorization
const { user, token } = await createAndLoginUser({
email: `e2e-authz-user-${Date.now()}@example.com`,
fullName: 'E2E AuthZ User',
});
regularUser = user;
regularUserAuthToken = token;
});
afterAll(async () => {
// Cleanup the created user
if (regularUser?.user.user_id) {
await cleanupDb({ userIds: [regularUser.user.user_id] });
}
});
// Define a list of admin-only endpoints to test
const adminEndpoints = [
{ method: 'GET', path: '/admin/stats', action: (token: string) => apiClient.getApplicationStats(token) },
{ method: 'GET', path: '/admin/users', action: (token: string) => apiClient.authedGet('/admin/users', { tokenOverride: token }) },
{ method: 'GET', path: '/admin/corrections', action: (token: string) => apiClient.getSuggestedCorrections(token) },
{ method: 'POST', path: '/admin/corrections/1/approve', action: (token: string) => apiClient.approveCorrection(1, token) },
{ method: 'POST', path: '/admin/trigger/daily-deal-check', action: (token: string) => apiClient.authedPostEmpty('/admin/trigger/daily-deal-check', { tokenOverride: token }) },
{ method: 'GET', path: '/admin/queues/status', action: (token: string) => apiClient.authedGet('/admin/queues/status', { tokenOverride: token }) },
];
it.each(adminEndpoints)('should return 403 Forbidden for a regular user trying to access $method $path', async ({ action }) => {
// Act: Attempt to access the admin endpoint with the regular user's token
const response = await action(regularUserAuthToken);
// Assert: The request should be forbidden
expect(response.status).toBe(403);
const errorData = await response.json();
expect(errorData.message).toBe('Forbidden: Administrator access required.');
});
});

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
/**
* @vitest-environment node
@@ -41,10 +42,22 @@ describe('E2E Admin Dashboard Flow', () => {
]);
// 3. Login to get the access token (now with admin privileges)
const loginResponse = await apiClient.loginUser(adminEmail, adminPassword, false);
// We poll because the direct DB write above runs in a separate transaction
// from the login API call. Due to PostgreSQL's `Read Committed` transaction
// isolation, the API might read the user's role before the test's update
// transaction is fully committed and visible. Polling makes the test resilient to this race condition.
const { response: loginResponse, data: loginData } = await poll(
async () => {
const response = await apiClient.loginUser(adminEmail, adminPassword, false);
// Clone to read body without consuming the original response stream
const data = response.ok ? await response.clone().json() : {};
return { response, data };
},
(result) => result.response.ok && result.data?.userprofile?.role === 'admin',
{ timeout: 10000, interval: 1000, description: 'user login with admin role' },
);
expect(loginResponse.status).toBe(200);
const loginData = await loginResponse.json();
authToken = loginData.token;
expect(authToken).toBeDefined();
// Verify the role returned in the login response is now 'admin'

View File

@@ -12,15 +12,17 @@ import type { UserProfile } from '../../types';
describe('Authentication E2E Flow', () => {
let testUser: UserProfile;
let testUserAuthToken: string;
const createdUserIds: string[] = [];
beforeAll(async () => {
// Create a user that can be used for login-related tests in this suite.
try {
const { user } = await createAndLoginUser({
const { user, token } = await createAndLoginUser({
email: `e2e-login-user-${Date.now()}@example.com`,
fullName: 'E2E Login User',
});
testUserAuthToken = token;
testUser = user;
createdUserIds.push(user.user.user_id);
} catch (error) {
@@ -119,12 +121,8 @@ describe('Authentication E2E Flow', () => {
});
it('should be able to access a protected route after logging in', async () => {
// Arrange: Log in to get a token
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const loginData = await loginResponse.json();
const token = loginData.token;
expect(loginResponse.status).toBe(200);
// Arrange: Use the token from the beforeAll hook
const token = testUserAuthToken;
expect(token).toBeDefined();
// Act: Use the token to access a protected route
@@ -140,11 +138,9 @@ describe('Authentication E2E Flow', () => {
});
it('should allow an authenticated user to update their profile', async () => {
// Arrange: Log in to get a token
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const loginData = await loginResponse.json();
const token = loginData.token;
expect(loginResponse.status).toBe(200);
// Arrange: Use the token from the beforeAll hook
const token = testUserAuthToken;
expect(token).toBeDefined();
const profileUpdates = {
full_name: 'E2E Updated Name',
@@ -229,4 +225,47 @@ describe('Authentication E2E Flow', () => {
expect(data.token).toBeUndefined();
});
});
describe('Token Refresh Flow', () => {
it('should allow an authenticated user to refresh their access token and use it', async () => {
// 1. Log in to get the refresh token cookie and an initial access token.
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
expect(loginResponse.status).toBe(200);
const loginData = await loginResponse.json();
const initialAccessToken = loginData.token;
// 2. Extract the refresh token from the 'set-cookie' header.
const setCookieHeader = loginResponse.headers.get('set-cookie');
expect(setCookieHeader, 'Set-Cookie header should be present in login response').toBeDefined();
// A typical Set-Cookie header might be 'refreshToken=...; Path=/; HttpOnly; Max-Age=...'. We just need the 'refreshToken=...' part.
const refreshTokenCookie = setCookieHeader!.split(';')[0];
// 3. Call the refresh token endpoint, passing the cookie.
// This assumes a new method in apiClient to handle this specific request.
const refreshResponse = await apiClient.refreshToken(refreshTokenCookie);
// 4. Assert the refresh was successful and we got a new token.
expect(refreshResponse.status).toBe(200);
const refreshData = await refreshResponse.json();
const newAccessToken = refreshData.token;
expect(newAccessToken).toBeDefined();
expect(newAccessToken).not.toBe(initialAccessToken);
// 5. Use the new access token to access a protected route.
const profileResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: newAccessToken });
expect(profileResponse.status).toBe(200);
const profileData = await profileResponse.json();
expect(profileData.user.user_id).toBe(testUser.user.user_id);
});
it('should fail to refresh with an invalid or missing token', async () => {
// Case 1: No cookie provided. This assumes refreshToken can handle an empty string.
const noCookieResponse = await apiClient.refreshToken('');
expect(noCookieResponse.status).toBe(401);
// Case 2: Invalid cookie provided
const invalidCookieResponse = await apiClient.refreshToken('refreshToken=invalid-garbage-token');
expect(invalidCookieResponse.status).toBe(403);
});
});
});

View File

@@ -2,6 +2,7 @@
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
/**
* @vitest-environment node
@@ -33,11 +34,21 @@ describe('E2E User Journey', () => {
const registerData = await registerResponse.json();
expect(registerData.message).toBe('User registered successfully!');
// 2. Login to get the access token
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
// 2. Login to get the access token.
// We poll here because even between two API calls (register and login),
// there can be a small delay before the newly created user record is visible
// to the transaction started by the login request. This prevents flaky test failures.
const { response: loginResponse, data: loginData } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const data = response.ok ? await response.clone().json() : {};
return { response, data };
},
(result) => result.response.ok,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
expect(loginResponse.status).toBe(200);
const loginData = await loginResponse.json();
authToken = loginData.token;
userId = loginData.userprofile.user.user_id;

View File

@@ -16,7 +16,6 @@ import { cleanupFiles } from '../utils/cleanupFiles';
import piexif from 'piexifjs';
import exifParser from 'exif-parser';
import sharp from 'sharp';
import { createFlyerAndItems } from '../../services/db/flyer.db';
/**
@@ -40,13 +39,13 @@ vi.mock('../../services/aiService.server', async (importOriginal) => {
return actual;
});
// Mock the database service to allow for simulating DB failures.
// Mock the main DB service to allow for simulating transaction failures.
// By default, it will use the real implementation.
vi.mock('../../services/db/flyer.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/flyer.db')>();
vi.mock('../../services/db/index.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/index.db')>();
return {
...actual,
createFlyerAndItems: vi.fn().mockImplementation(actual.createFlyerAndItems),
withTransaction: vi.fn().mockImplementation(actual.withTransaction),
};
});
@@ -84,9 +83,10 @@ describe('Flyer Processing Background Job Integration Test', () => {
// 2. Restore DB Service Mock to real implementation
// This ensures that unless a test specifically mocks a failure, the DB logic works as expected.
const actual = await vi.importActual<typeof import('../../services/db/flyer.db')>('../../services/db/flyer.db');
vi.mocked(createFlyerAndItems).mockReset();
vi.mocked(createFlyerAndItems).mockImplementation(actual.createFlyerAndItems);
const { withTransaction } = await import('../../services/db/index.db');
const actualDb = await vi.importActual<typeof import('../../services/db/index.db')>('../../services/db/index.db');
vi.mocked(withTransaction).mockReset();
vi.mocked(withTransaction).mockImplementation(actualDb.withTransaction);
});
afterAll(async () => {
@@ -128,6 +128,9 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadReq = request
.post('/api/ai/upload-and-process')
.field('checksum', checksum)
// Pass the baseUrl directly in the form data to ensure the worker receives it,
// bypassing issues with vi.stubEnv in multi-threaded test environments.
.field('baseUrl', 'http://localhost:3000')
.attach('flyerFile', uniqueContent, uniqueFileName);
if (token) {
uploadReq.set('Authorization', `Bearer ${token}`);
@@ -245,6 +248,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', 'http://localhost:3000')
.field('checksum', checksum)
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
@@ -329,6 +333,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', 'http://localhost:3000')
.field('checksum', checksum)
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
@@ -377,7 +382,7 @@ it(
async () => {
// Arrange: Mock the AI service to throw an error for this specific test.
const aiError = new Error('AI model failed to extract data.');
mockExtractCoreData.mockRejectedValueOnce(aiError);
mockExtractCoreData.mockRejectedValue(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -394,6 +399,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -424,9 +430,11 @@ it(
it(
'should handle a database failure during flyer creation',
async () => {
// Arrange: Mock the database creation function to throw an error for this specific test.
// Arrange: Mock the database transaction function to throw an error.
// This is a more realistic simulation of a DB failure than mocking the inner createFlyerAndItems function.
const dbError = new Error('DB transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValueOnce(dbError);
const { withTransaction } = await import('../../services/db/index.db');
vi.mocked(withTransaction).mockRejectedValue(dbError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -443,6 +451,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -475,7 +484,7 @@ it(
async () => {
// Arrange: Mock the AI service to throw an error, causing the job to fail.
const aiError = new Error('Simulated AI failure for cleanup test.');
mockExtractCoreData.mockRejectedValueOnce(aiError);
mockExtractCoreData.mockRejectedValue(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -496,6 +505,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);

View File

@@ -333,7 +333,7 @@ describe('User API Routes Integration Tests', () => {
const updatedProfile = response.body;
expect(updatedProfile.avatar_url).toBeDefined();
expect(updatedProfile.avatar_url).not.toBeNull();
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/avatar-');
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar');
// Assert (Verification): Fetch the profile again to ensure the change was persisted
const verifyResponse = await request
@@ -375,6 +375,6 @@ describe('User API Routes Integration Tests', () => {
// Assert: Check for a 400 Bad Request response from the multer error handler.
expect(response.status).toBe(400);
expect(response.body.message).toBe('File too large');
expect(response.body.message).toBe('File upload error: File too large');
});
});

View File

@@ -71,11 +71,11 @@ describe('generateFlyerIcon', () => {
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
// Check that sharp was called with the correct source
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath);
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath, { failOn: 'none' });
// Check the processing chain
expect(mocks.resize).toHaveBeenCalledWith(64, 64, { fit: 'cover' });
expect(mocks.webp).toHaveBeenCalledWith({ quality: 80 });
expect(mocks.resize).toHaveBeenCalledWith({ width: 128, height: 128, fit: 'inside' });
expect(mocks.webp).toHaveBeenCalledWith({ quality: 75 });
expect(mocks.toFile).toHaveBeenCalledWith('/path/to/icons/icon-flyer-image-1.webp');
// Check the returned filename
@@ -87,8 +87,11 @@ describe('generateFlyerIcon', () => {
mocks.toFile.mockRejectedValue(sharpError);
await expect(
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger),
).rejects.toThrow('Icon generation failed.');
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), 'Failed to generate flyer icon:');
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger), // This was a duplicate, fixed.
).rejects.toThrow('Failed to generate icon for /path/to/bad-image.jpg.');
expect(logger.error).toHaveBeenCalledWith(
{ err: sharpError, sourcePath: '/path/to/bad-image.jpg', outputPath: '/path/to/icons/icon-bad-image.webp' },
'An error occurred during icon generation.',
);
});
});

View File

@@ -3,6 +3,7 @@ import sharp from 'sharp';
import path from 'path';
import fs from 'node:fs/promises';
import type { Logger } from 'pino';
import { sanitizeFilename } from './stringUtils';
/**
* Processes an uploaded image file by stripping all metadata (like EXIF)
@@ -66,8 +67,9 @@ export async function generateFlyerIcon(
outputDir: string,
logger: Logger,
): Promise<string> {
// Use the source file's name (without extension) to create the icon name.
const iconFileName = `icon-${path.parse(sourcePath).name}.webp`;
// Sanitize the base name of the source file to create a clean icon name.
const sourceBaseName = path.parse(sourcePath).name;
const iconFileName = `icon-${sanitizeFilename(sourceBaseName)}.webp`;
const outputPath = path.join(outputDir, iconFileName);
try {