Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
e675c1a73c ci: Bump version to 0.9.48 [skip ci] 2026-01-07 01:35:26 +05:00
3c19084a0a fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m17s
2026-01-06 12:34:18 -08:00
19 changed files with 434 additions and 61 deletions

4
package-lock.json generated
View File

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

View File

@@ -250,19 +250,37 @@ export class AIService {
// If the call succeeds, return the result immediately.
return result;
} catch (error: unknown) {
lastError = error instanceof Error ? error : new Error(String(error));
const errorMessage = (lastError.message || '').toLowerCase(); // Make case-insensitive
// Robust error message extraction to handle various error shapes (Error objects, JSON responses, etc.)
let errorMsg = '';
if (error instanceof Error) {
lastError = error;
errorMsg = error.message;
} else {
try {
if (typeof error === 'object' && error !== null && 'message' in error) {
errorMsg = String((error as any).message);
} else {
errorMsg = JSON.stringify(error);
}
} catch {
errorMsg = String(error);
}
lastError = new Error(errorMsg);
}
const lowerErrorMsg = errorMsg.toLowerCase();
// Check for specific error messages indicating quota issues or model unavailability.
if (
errorMessage.includes('quota') ||
errorMessage.includes('429') || // HTTP 429 Too Many Requests
errorMessage.includes('resource_exhausted') || // Make case-insensitive
errorMessage.includes('model is overloaded') ||
errorMessage.includes('not found') // Also retry if model is not found (e.g., regional availability or API version issue)
lowerErrorMsg.includes('quota') ||
lowerErrorMsg.includes('429') || // HTTP 429 Too Many Requests
lowerErrorMsg.includes('503') || // HTTP 503 Service Unavailable
lowerErrorMsg.includes('resource_exhausted') ||
lowerErrorMsg.includes('overloaded') || // Covers "model is overloaded"
lowerErrorMsg.includes('unavailable') || // Covers "Service Unavailable"
lowerErrorMsg.includes('not found') // Also retry if model is not found (e.g., regional availability or API version issue)
) {
this.logger.warn(
`[AIService Adapter] Model '${modelName}' failed due to quota/rate limit. Trying next model. Error: ${errorMessage}`,
`[AIService Adapter] Model '${modelName}' failed due to quota/rate limit/overload. Trying next model. Error: ${errorMsg}`,
);
continue; // Try the next model in the list.
} else {

View File

@@ -86,6 +86,30 @@ describe('AnalyticsService', () => {
'Daily analytics job failed.',
);
});
it('should handle non-Error objects thrown during processing', async () => {
const job = createMockJob<AnalyticsJobData>({ reportDate: '2023-10-27' } as AnalyticsJobData);
mockLoggerInstance.info
.mockImplementationOnce(() => {}) // "Picked up..."
.mockImplementationOnce(() => {
throw 'A string error';
});
const promise = service.processDailyReportJob(job);
await vi.advanceTimersByTimeAsync(10000);
await expect(promise).rejects.toThrow('A string error');
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({ message: 'A string error' }),
attemptsMade: 1,
}),
'Daily analytics job failed.',
);
});
});
describe('processWeeklyReportJob', () => {
@@ -149,5 +173,32 @@ describe('AnalyticsService', () => {
'Weekly analytics job failed.',
);
});
it('should handle non-Error objects thrown during processing', async () => {
const job = createMockJob<WeeklyAnalyticsJobData>({
reportYear: 2023,
reportWeek: 43,
} as WeeklyAnalyticsJobData);
mockLoggerInstance.info
.mockImplementationOnce(() => {}) // "Picked up..."
.mockImplementationOnce(() => {
throw 'A string error';
});
const promise = service.processWeeklyReportJob(job);
await vi.advanceTimersByTimeAsync(30000);
await expect(promise).rejects.toThrow('A string error');
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({ message: 'A string error' }),
attemptsMade: 1,
}),
'Weekly analytics job failed.',
);
});
});
});

View File

@@ -947,7 +947,10 @@ describe('API Client', () => {
it('trackFlyerItemInteraction should log a warning on failure', async () => {
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
// Mock global.fetch to throw an error directly to ensure the catch block is hit.
vi.spyOn(global, 'fetch').mockImplementationOnce(() => {
throw apiError;
});
const { logger } = await import('./logger.client');
// We can now await this properly because we added 'return' in apiClient.ts
@@ -959,7 +962,10 @@ describe('API Client', () => {
it('logSearchQuery should log a warning on failure', async () => {
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
// Mock global.fetch to throw an error directly to ensure the catch block is hit.
vi.spyOn(global, 'fetch').mockImplementationOnce(() => {
throw apiError;
});
const { logger } = await import('./logger.client');
const queryData = createMockSearchQueryPayload({

View File

@@ -35,6 +35,7 @@ describe('AuthService', () => {
let DatabaseError: typeof import('./processingErrors').DatabaseError;
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
let RepositoryError: typeof import('./db/errors.db').RepositoryError;
let ValidationError: typeof import('./db/errors.db').ValidationError;
let withTransaction: typeof import('./db/index.db').withTransaction;
const reqLog = {}; // Mock request logger object
@@ -109,6 +110,7 @@ describe('AuthService', () => {
DatabaseError = (await import('./processingErrors')).DatabaseError;
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
RepositoryError = (await import('./db/errors.db')).RepositoryError;
ValidationError = (await import('./db/errors.db')).ValidationError;
});
afterEach(() => {
@@ -168,6 +170,15 @@ describe('AuthService', () => {
expect(logger.error).toHaveBeenCalledWith({ error, email: 'test@example.com' }, `User registration failed with an unexpected error.`);
});
it('should throw ValidationError if password is weak', async () => {
const { validatePasswordStrength } = await import('../utils/authUtils');
vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: false, feedback: 'Password too weak' });
await expect(
authService.registerUser('test@example.com', 'weak', 'Test User', undefined, reqLog),
).rejects.toThrow(ValidationError);
});
});
describe('registerAndLoginUser', () => {
@@ -285,6 +296,25 @@ describe('AuthService', () => {
);
expect(logger.error).toHaveBeenCalled();
});
it('should log error if sending email fails but still return token', async () => {
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');
const emailError = new Error('Email failed');
vi.mocked(sendPasswordResetEmail).mockRejectedValue(emailError);
const result = await authService.resetPassword('test@example.com', reqLog);
expect(logger.error).toHaveBeenCalledWith({ emailError }, `Email send failure during password reset for user`);
expect(result).toBe('mocked_random_id');
});
it('should re-throw RepositoryError', async () => {
const repoError = new RepositoryError('Repo error', 500);
vi.mocked(userRepo.findUserByEmail).mockRejectedValue(repoError);
await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(repoError);
});
});
describe('updatePassword', () => {
@@ -334,6 +364,22 @@ describe('AuthService', () => {
expect(transactionalUserRepoMocks.updateUserPassword).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should throw ValidationError if new password is weak', async () => {
const { validatePasswordStrength } = await import('../utils/authUtils');
vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: false, feedback: 'Password too weak' });
await expect(
authService.updatePassword('token', 'weak', reqLog),
).rejects.toThrow(ValidationError);
});
it('should re-throw RepositoryError from transaction', async () => {
const repoError = new RepositoryError('Repo error', 500);
vi.mocked(withTransaction).mockRejectedValue(repoError);
await expect(authService.updatePassword('token', 'newPass', reqLog)).rejects.toThrow(repoError);
});
});
describe('getUserByRefreshToken', () => {

View File

@@ -161,6 +161,13 @@ describe('Background Job Service', () => {
{ jobId: expect.stringContaining('manual-weekly-report-') },
);
});
it('should throw if job ID is not returned from the queue', async () => {
// Mock the queue to return a job object without an 'id' property
vi.mocked(weeklyAnalyticsQueue.add).mockResolvedValue({ name: 'test-job' } as any);
await expect(service.triggerWeeklyAnalyticsReport()).rejects.toThrow();
});
});
it('should do nothing if no deals are found for any user', async () => {
@@ -177,6 +184,35 @@ describe('Background Job Service', () => {
expect(mockNotificationRepo.createBulkNotifications).not.toHaveBeenCalled();
});
it('should process a single user successfully and log notification creation', async () => {
const singleUserDeal = [
{
...createMockWatchedItemDeal({
master_item_id: 1,
item_name: 'Apples',
best_price_in_cents: 199,
}),
user_id: 'user-1',
email: 'user1@test.com',
full_name: 'User One',
},
];
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(singleUserDeal);
mockEmailQueue.add.mockResolvedValue({ id: 'job-1' });
await service.runDailyDealCheck();
expect(mockEmailQueue.add).toHaveBeenCalledTimes(1);
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
expect(notificationPayload).toHaveLength(1);
// This assertion specifically targets line 180
expect(mockServiceLogger.info).toHaveBeenCalledWith(
`[BackgroundJob] Successfully created 1 in-app notifications.`,
);
});
it('should create notifications and enqueue emails when deals are found', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);

View File

@@ -361,7 +361,7 @@ describe('Flyer DB Service', () => {
);
});
it('should sanitize empty or whitespace-only price_display to null', async () => {
it('should sanitize empty or whitespace-only price_display to "N/A"', async () => {
const itemsData: FlyerItemInsert[] = [
{
item: 'Free Item',
@@ -396,7 +396,7 @@ describe('Flyer DB Service', () => {
expect(queryValues).toEqual([
1, // flyerId for item 1
'Free Item',
null, // Sanitized price_display for item 1
"N/A", // Sanitized price_display for item 1
0,
'1',
'Promo',
@@ -404,7 +404,7 @@ describe('Flyer DB Service', () => {
0,
1, // flyerId for item 2
'Whitespace Item',
null, // Sanitized price_display for item 2
"N/A", // Sanitized price_display for item 2
null,
'1',
'Promo',

View File

@@ -50,6 +50,7 @@ describe('Email Service (Server)', () => {
beforeEach(async () => {
console.log('[TEST SETUP] Setting up Email Service mocks');
vi.clearAllMocks();
vi.stubEnv('FRONTEND_URL', 'https://test.flyer.com');
// Reset to default successful implementation
mocks.sendMail.mockImplementation((mailOptions: { to: string }) => {
console.log('[TEST DEBUG] mockSendMail (default) called with:', mailOptions?.to);
@@ -60,12 +61,17 @@ describe('Email Service (Server)', () => {
});
});
describe('sendPasswordResetEmail', () => {
it('should call sendMail with the correct recipient, subject, and link', async () => {
const to = 'test@example.com';
const resetLink = 'http://localhost:3000/reset/mock-token-123';
afterEach(() => {
vi.unstubAllEnvs();
});
await sendPasswordResetEmail(to, resetLink, logger);
describe('sendPasswordResetEmail', () => {
it('should call sendMail with the correct recipient, subject, and constructed link', async () => {
const to = 'test@example.com';
const token = 'mock-token-123';
const expectedResetUrl = `https://test.flyer.com/reset-password?token=${token}`;
await sendPasswordResetEmail(to, token, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as {
@@ -77,9 +83,8 @@ describe('Email Service (Server)', () => {
expect(mailOptions.to).toBe(to);
expect(mailOptions.subject).toBe('Your Password Reset Request');
expect(mailOptions.text).toContain(resetLink);
// The implementation constructs the link, so we check that our mock link is present inside the href
expect(mailOptions.html).toContain(resetLink);
expect(mailOptions.text).toContain(expectedResetUrl);
expect(mailOptions.html).toContain(`href="${expectedResetUrl}"`);
});
});
@@ -269,5 +274,22 @@ describe('Email Service (Server)', () => {
'Email job failed.',
);
});
it('should handle non-Error objects thrown during processing', async () => {
const job = createMockJob(mockJobData);
const emailErrorString = 'SMTP Connection Failed as a string';
mocks.sendMail.mockRejectedValue(emailErrorString);
await expect(processEmailJob(job)).rejects.toThrow(emailErrorString);
expect(logger.error).toHaveBeenCalledWith(
{
err: expect.objectContaining({ message: emailErrorString }),
jobData: mockJobData,
attemptsMade: 1,
},
'Email job failed.',
);
});
});
});

View File

@@ -6,6 +6,9 @@ import {
AiDataValidationError,
GeocodingFailedError,
UnsupportedFileTypeError,
TransformationError,
DatabaseError,
ImageConversionError,
} from './processingErrors';
describe('Processing Errors', () => {
@@ -18,6 +21,30 @@ describe('Processing Errors', () => {
expect(error).toBeInstanceOf(FlyerProcessingError);
expect(error.message).toBe(message);
expect(error.name).toBe('FlyerProcessingError');
expect(error.errorCode).toBe('UNKNOWN_ERROR');
expect(error.userMessage).toBe(message);
});
it('should allow setting a custom errorCode and userMessage', () => {
const message = 'Internal error';
const errorCode = 'CUSTOM_ERROR';
const userMessage = 'Something went wrong for you.';
const error = new FlyerProcessingError(message, errorCode, userMessage);
expect(error.errorCode).toBe(errorCode);
expect(error.userMessage).toBe(userMessage);
});
it('should return the correct error payload', () => {
const message = 'Internal error';
const errorCode = 'CUSTOM_ERROR';
const userMessage = 'Something went wrong for you.';
const error = new FlyerProcessingError(message, errorCode, userMessage);
expect(error.toErrorPayload()).toEqual({
errorCode,
message: userMessage,
});
});
});
@@ -32,6 +59,7 @@ describe('Processing Errors', () => {
expect(error.message).toBe(message);
expect(error.name).toBe('PdfConversionError');
expect(error.stderr).toBeUndefined();
expect(error.errorCode).toBe('PDF_CONVERSION_FAILED');
});
it('should store the stderr property if provided', () => {
@@ -42,6 +70,16 @@ describe('Processing Errors', () => {
expect(error.message).toBe(message);
expect(error.stderr).toBe(stderr);
});
it('should include stderr in the error payload', () => {
const message = 'pdftocairo failed.';
const stderr = 'pdftocairo: command not found';
const error = new PdfConversionError(message, stderr);
const payload = error.toErrorPayload();
expect(payload.errorCode).toBe('PDF_CONVERSION_FAILED');
expect(payload.stderr).toBe(stderr);
});
});
describe('AiDataValidationError', () => {
@@ -58,6 +96,58 @@ describe('Processing Errors', () => {
expect(error.name).toBe('AiDataValidationError');
expect(error.validationErrors).toEqual(validationErrors);
expect(error.rawData).toEqual(rawData);
expect(error.errorCode).toBe('AI_VALIDATION_FAILED');
});
it('should include validationErrors and rawData in the error payload', () => {
const message = 'AI response validation failed.';
const validationErrors = { fieldErrors: { store_name: ['Store name cannot be empty'] } };
const rawData = { store_name: '', items: [] };
const error = new AiDataValidationError(message, validationErrors, rawData);
const payload = error.toErrorPayload();
expect(payload.errorCode).toBe('AI_VALIDATION_FAILED');
expect(payload.validationErrors).toEqual(validationErrors);
expect(payload.rawData).toEqual(rawData);
});
});
describe('TransformationError', () => {
it('should create an error with the correct message and code', () => {
const message = 'Transformation failed.';
const error = new TransformationError(message);
expect(error).toBeInstanceOf(FlyerProcessingError);
expect(error).toBeInstanceOf(TransformationError);
expect(error.message).toBe(message);
expect(error.errorCode).toBe('TRANSFORMATION_FAILED');
expect(error.userMessage).toBe('There was a problem transforming the flyer data. Please check the input.');
});
});
describe('DatabaseError', () => {
it('should create an error with the correct message and code', () => {
const message = 'DB failed.';
const error = new DatabaseError(message);
expect(error).toBeInstanceOf(FlyerProcessingError);
expect(error).toBeInstanceOf(DatabaseError);
expect(error.message).toBe(message);
expect(error.errorCode).toBe('DATABASE_ERROR');
expect(error.userMessage).toBe('A database operation failed. Please try again later.');
});
});
describe('ImageConversionError', () => {
it('should create an error with the correct message and code', () => {
const message = 'Image conversion failed.';
const error = new ImageConversionError(message);
expect(error).toBeInstanceOf(FlyerProcessingError);
expect(error).toBeInstanceOf(ImageConversionError);
expect(error.message).toBe(message);
expect(error.errorCode).toBe('IMAGE_CONVERSION_FAILED');
expect(error.userMessage).toBe('The uploaded image could not be processed. It might be corrupt or in an unsupported format.');
});
});
@@ -71,6 +161,7 @@ describe('Processing Errors', () => {
expect(error).toBeInstanceOf(GeocodingFailedError);
expect(error.message).toBe(message);
expect(error.name).toBe('GeocodingFailedError');
expect(error.errorCode).toBe('GEOCODING_FAILED');
});
});
@@ -84,6 +175,7 @@ describe('Processing Errors', () => {
expect(error).toBeInstanceOf(UnsupportedFileTypeError);
expect(error.message).toBe(message);
expect(error.name).toBe('UnsupportedFileTypeError');
expect(error.errorCode).toBe('UNSUPPORTED_FILE_TYPE');
});
});
});

View File

@@ -251,6 +251,19 @@ describe('Worker Service Lifecycle', () => {
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should log an error if Redis connection fails to close', async () => {
const quitError = new Error('Redis quit failed');
mockRedisConnection.quit.mockRejectedValueOnce(quitError);
await gracefulShutdown('SIGTERM');
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: quitError, resource: 'redisConnection' },
'[Shutdown] Error closing Redis connection.',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should timeout if shutdown takes too long', async () => {
vi.useFakeTimers();
// Make one of the close calls hang indefinitely

View File

@@ -260,6 +260,33 @@ describe('UserService', () => {
vi.unstubAllEnvs();
});
it('should re-throw NotFoundError if user profile does not exist', async () => {
const { logger } = await import('./logger.server');
const userId = 'user-not-found';
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
const notFoundError = new NotFoundError('User not found');
mocks.mockUpdateUserProfile.mockRejectedValue(notFoundError);
await expect(userService.updateUserAvatar(userId, file, logger)).rejects.toThrow(
NotFoundError,
);
});
it('should wrap generic errors in a DatabaseError', async () => {
const { logger } = await import('./logger.server');
const userId = 'user-123';
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
const genericError = new Error('DB connection failed');
mocks.mockUpdateUserProfile.mockRejectedValue(genericError);
await expect(userService.updateUserAvatar(userId, file, logger)).rejects.toThrow(
DatabaseError,
);
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Failed to update user avatar: ${genericError.message}`);
});
});
describe('updateUserPassword', () => {
@@ -276,6 +303,19 @@ describe('UserService', () => {
expect(bcrypt.hash).toHaveBeenCalledWith(newPassword, 10);
expect(mocks.mockUpdateUserPassword).toHaveBeenCalledWith(userId, hashedPassword, logger);
});
it('should wrap generic errors in a DatabaseError', async () => {
const { logger } = await import('./logger.server');
const userId = 'user-123';
const newPassword = 'new-password';
const genericError = new Error('DB write failed');
vi.mocked(bcrypt.hash).mockResolvedValue();
mocks.mockUpdateUserPassword.mockRejectedValue(genericError);
await expect(userService.updateUserPassword(userId, newPassword, logger)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Failed to update user password: ${genericError.message}`);
});
});
describe('deleteUserAccount', () => {
@@ -318,6 +358,22 @@ describe('UserService', () => {
).rejects.toThrow(ValidationError);
expect(mocks.mockDeleteUserById).not.toHaveBeenCalled();
});
it('should wrap generic errors in a DatabaseError', async () => {
const { logger } = await import('./logger.server');
const userId = 'user-123';
const password = 'password';
const genericError = new Error('Something went wrong');
mocks.mockFindUserWithPasswordHashById.mockResolvedValue({
user_id: userId,
password_hash: 'hashed-password',
});
vi.mocked(bcrypt.compare).mockRejectedValue(genericError);
await expect(userService.deleteUserAccount(userId, password, logger)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Failed to delete user account: ${genericError.message}`);
});
});
describe('getUserAddress', () => {
@@ -365,5 +421,17 @@ describe('UserService', () => {
);
expect(mocks.mockDeleteUserById).not.toHaveBeenCalled();
});
it('should wrap generic errors in a DatabaseError', async () => {
const { logger } = await import('./logger.server');
const deleterId = 'admin-1';
const targetId = 'user-2';
const genericError = new Error('DB write failed');
mocks.mockDeleteUserById.mockRejectedValue(genericError);
await expect(userService.deleteUserAsAdmin(deleterId, targetId, logger)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Admin failed to delete user account: ${genericError.message}`);
});
});
});

View File

@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => {
// Return a mock worker instance, though it's not used in this test file.
return { on: vi.fn(), close: vi.fn() };
}),
fsReaddir: vi.fn(),
fsUnlink: vi.fn(),
};
});
@@ -51,7 +53,8 @@ vi.mock('./userService', () => ({
// that the adapter is built from in queueService.server.ts.
vi.mock('node:fs/promises', () => ({
default: {
// unlink is no longer directly called by the worker
readdir: mocks.fsReaddir,
unlink: mocks.fsUnlink,
},
}));
@@ -279,4 +282,18 @@ describe('Queue Workers', () => {
await expect(tokenCleanupProcessor(job)).rejects.toThrow(dbError);
});
});
describe('fsAdapter', () => {
it('should call fsPromises.readdir', async () => {
const { fsAdapter } = await import('./workers.server');
await fsAdapter.readdir('/tmp', { withFileTypes: true });
expect(mocks.fsReaddir).toHaveBeenCalledWith('/tmp', { withFileTypes: true });
});
it('should call fsPromises.unlink', async () => {
const { fsAdapter } = await import('./workers.server');
await fsAdapter.unlink('/tmp/file');
expect(mocks.fsUnlink).toHaveBeenCalledWith('/tmp/file');
});
});
});

View File

@@ -36,7 +36,7 @@ const execAsync = promisify(exec);
// --- Worker Instantiation ---
const fsAdapter: IFileSystem = {
export const fsAdapter: IFileSystem = {
readdir: (path: string, options: { withFileTypes: true }) => fsPromises.readdir(path, options),
unlink: (path: string) => fsPromises.unlink(path),
};

View File

@@ -3,7 +3,6 @@ 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
@@ -42,20 +41,16 @@ describe('E2E Admin Dashboard Flow', () => {
]);
// 3. Login to get the access token (now with admin privileges)
// 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' },
);
// We wait briefly to ensure the DB transaction is committed and visible to the API,
// and to provide a buffer for any rate limits from previous tests.
await new Promise((resolve) => setTimeout(resolve, 2000));
const loginResponse = await apiClient.loginUser(adminEmail, adminPassword, false);
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Failed to log in as admin: ${loginResponse.status} ${errorText}`);
}
const loginData = await loginResponse.json();
expect(loginResponse.status).toBe(200);
authToken = loginData.token;

View File

@@ -182,17 +182,11 @@ describe('Authentication E2E Flow', () => {
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
// Poll for the password reset token.
const { response: forgotResponse, token: resetToken } = await poll(
async () => {
const response = await apiClient.requestPasswordReset(email);
// Clone to read body without consuming the original response stream
const data = response.ok ? await response.clone().json() : {};
return { response, token: data.token };
},
(result) => !!result.token,
{ timeout: 10000, interval: 1000, description: 'password reset token generation' },
);
// Request password reset (do not poll, as this endpoint is rate-limited)
const forgotResponse = await apiClient.requestPasswordReset(email);
expect(forgotResponse.status).toBe(200);
const forgotData = await forgotResponse.json();
const resetToken = forgotData.token;
// Assert 1: Check that we received a token.
expect(resetToken, 'Backend returned 200 but no token. Check backend logs for "Connection terminated" errors.').toBeDefined();
@@ -217,8 +211,18 @@ describe('Authentication E2E Flow', () => {
});
it('should return a generic success message for a non-existent email to prevent enumeration', async () => {
// Add a small delay to ensure we don't hit the rate limit (5 RPM) if tests run too fast
await new Promise((resolve) => setTimeout(resolve, 2000));
const nonExistentEmail = `non-existent-e2e-${Date.now()}@example.com`;
const response = await apiClient.requestPasswordReset(nonExistentEmail);
// Check for rate limiting or other errors before parsing JSON to avoid SyntaxError
if (!response.ok) {
const text = await response.text();
throw new Error(`Request failed with status ${response.status}: ${text}`);
}
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe('If an account with that email exists, a password reset link has been sent.');
@@ -240,6 +244,10 @@ describe('Authentication E2E Flow', () => {
// A typical Set-Cookie header might be 'refreshToken=...; Path=/; HttpOnly; Max-Age=...'. We just need the 'refreshToken=...' part.
const refreshTokenCookie = setCookieHeader!.split(';')[0];
// Wait for >1 second to ensure the 'iat' (Issued At) claim in the new JWT changes.
// JWT timestamps have second-level precision.
await new Promise((resolve) => setTimeout(resolve, 1100));
// 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);

View File

@@ -8,7 +8,7 @@ import { getPool } from '../../services/db/connection.db';
import { generateFileChecksum } from '../../utils/checksum';
import { logger } from '../../services/logger.server';
import type { UserProfile, ExtractedFlyerItem } from '../../types';
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { cleanupFiles } from '../utils/cleanupFiles';
@@ -135,7 +135,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName));
// Act 1: Upload the file to start the background job.
const testBaseUrl = getTestBaseUrl();
const testBaseUrl = 'https://example.com';
console.log('[TEST ACTION] Uploading file with baseUrl:', testBaseUrl);
const uploadReq = request
@@ -264,7 +264,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', getTestBaseUrl())
.field('baseUrl', 'https://example.com')
.field('checksum', checksum)
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
@@ -353,7 +353,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', getTestBaseUrl())
.field('baseUrl', 'https://example.com')
.field('checksum', checksum)
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
@@ -424,7 +424,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', getTestBaseUrl())
.field('baseUrl', 'https://example.com')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -476,7 +476,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', getTestBaseUrl())
.field('baseUrl', 'https://example.com')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -530,7 +530,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', getTestBaseUrl())
.field('baseUrl', 'https://example.com')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);

View File

@@ -121,6 +121,7 @@ describe('Gamification Flow Integration Test', () => {
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.field('checksum', checksum)
.field('baseUrl', 'https://example.com')
.attach('flyerFile', uniqueContent, uniqueFileName);
const { jobId } = uploadResponse.body;
@@ -254,7 +255,7 @@ describe('Gamification Flow Integration Test', () => {
// 8. Assert that the URLs are fully qualified.
expect(savedFlyer.image_url).to.equal(newFlyer.image_url);
expect(savedFlyer.icon_url).to.equal(newFlyer.icon_url);
const expectedBaseUrl = getTestBaseUrl();
const expectedBaseUrl = 'https://example.com';
expect(newFlyer.image_url).toContain(`${expectedBaseUrl}/flyer-images/`);
});
});

View File

@@ -228,7 +228,7 @@ describe('Public API Routes Integration Tests', () => {
describe('Rate Limiting on Public Routes', () => {
it('should block requests to /api/personalization/master-items after exceeding the limit', async () => {
// The limit might be higher than 5. We loop enough times to ensure we hit the rate limit.
const maxRequests = 30;
const maxRequests = 120; // Increased to ensure we hit the limit (likely 60 or 100)
let blockedResponse: any;
for (let i = 0; i < maxRequests; i++) {