Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecec686347 | ||
| 86de680080 | |||
|
|
0371947065 | ||
| 296698758c |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.5",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -63,7 +63,8 @@ describe('FlyerAiProcessor', () => {
|
||||
};
|
||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||
|
||||
const result = await service.extractAndValidateData([], jobData, logger);
|
||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||
|
||||
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
|
||||
expect(mockPersonalizationRepo.getAllMasterItems).toHaveBeenCalledTimes(1);
|
||||
@@ -83,7 +84,8 @@ describe('FlyerAiProcessor', () => {
|
||||
};
|
||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
|
||||
|
||||
await expect(service.extractAndValidateData([], jobData, logger)).rejects.toThrow(
|
||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
|
||||
AiDataValidationError,
|
||||
);
|
||||
});
|
||||
@@ -101,7 +103,8 @@ describe('FlyerAiProcessor', () => {
|
||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse as any);
|
||||
const { logger } = await import('./logger.server');
|
||||
|
||||
const result = await service.extractAndValidateData([], jobData, logger);
|
||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||
|
||||
// It should not throw, but return the data and log a warning.
|
||||
expect(result.data).toEqual(mockAiResponse);
|
||||
@@ -122,7 +125,8 @@ describe('FlyerAiProcessor', () => {
|
||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||
const { logger } = await import('./logger.server');
|
||||
|
||||
const result = await service.extractAndValidateData([], jobData, logger);
|
||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||
expect(result.data).toEqual(mockAiResponse);
|
||||
expect(result.needsReview).toBe(true);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('contains no items. The flyer will be saved with an item_count of 0. Flagging for review.'));
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
/**
|
||||
* This class is responsible for transforming the validated data from the AI service
|
||||
@@ -57,41 +58,47 @@ export class FlyerDataTransformer {
|
||||
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
|
||||
logger.info('Starting data transformation from AI output to database format.');
|
||||
|
||||
const { data: extractedData, needsReview } = aiResult;
|
||||
try {
|
||||
const { data: extractedData, needsReview } = aiResult;
|
||||
|
||||
const firstImage = imagePaths[0].path;
|
||||
const iconFileName = await generateFlyerIcon(
|
||||
firstImage,
|
||||
path.join(path.dirname(firstImage), 'icons'),
|
||||
logger,
|
||||
);
|
||||
const firstImage = imagePaths[0].path;
|
||||
const iconFileName = await generateFlyerIcon(
|
||||
firstImage,
|
||||
path.join(path.dirname(firstImage), 'icons'),
|
||||
logger,
|
||||
);
|
||||
|
||||
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
||||
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
||||
|
||||
const storeName = extractedData.store_name || 'Unknown Store (auto)';
|
||||
if (!extractedData.store_name) {
|
||||
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
||||
const storeName = extractedData.store_name || 'Unknown Store (auto)';
|
||||
if (!extractedData.store_name) {
|
||||
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
||||
}
|
||||
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: originalFileName,
|
||||
image_url: `/flyer-images/${path.basename(firstImage)}`,
|
||||
icon_url: `/flyer-images/icons/${iconFileName}`,
|
||||
checksum,
|
||||
store_name: storeName,
|
||||
valid_from: extractedData.valid_from,
|
||||
valid_to: extractedData.valid_to,
|
||||
store_address: extractedData.store_address, // The number of items is now calculated directly from the transformed data.
|
||||
item_count: itemsForDb.length,
|
||||
uploaded_by: userId,
|
||||
status: needsReview ? 'needs_review' : 'processed',
|
||||
};
|
||||
|
||||
logger.info(
|
||||
{ itemCount: itemsForDb.length, storeName: flyerData.store_name },
|
||||
'Data transformation complete.',
|
||||
);
|
||||
|
||||
return { flyerData, itemsForDb };
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Transformation process failed');
|
||||
// Wrap and rethrow with the new error class
|
||||
throw new TransformationError('Flyer Data Transformation Failed');
|
||||
}
|
||||
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: originalFileName,
|
||||
image_url: `/flyer-images/${path.basename(firstImage)}`,
|
||||
icon_url: `/flyer-images/icons/${iconFileName}`,
|
||||
checksum,
|
||||
store_name: storeName,
|
||||
valid_from: extractedData.valid_from,
|
||||
valid_to: extractedData.valid_to,
|
||||
store_address: extractedData.store_address, // The number of items is now calculated directly from the transformed data.
|
||||
item_count: itemsForDb.length,
|
||||
uploaded_by: userId,
|
||||
status: needsReview ? 'needs_review' : 'processed',
|
||||
};
|
||||
|
||||
logger.info(
|
||||
{ itemCount: itemsForDb.length, storeName: flyerData.store_name },
|
||||
'Data transformation complete.',
|
||||
);
|
||||
|
||||
return { flyerData, itemsForDb };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// src/services/flyerProcessingService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
import { Job, UnrecoverableError } from 'bullmq';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import type { Logger } from 'pino';
|
||||
import { z } from 'zod';
|
||||
import { AiFlyerDataSchema } from './flyerAiProcessor.server';
|
||||
import type { Flyer, FlyerInsert, FlyerItemInsert } from '../types';
|
||||
import { AiFlyerDataSchema } from '../types/ai';
|
||||
import type { FlyerInsert } from '../types';
|
||||
import type { CleanupJobData, FlyerJobData } from '../types/job-data';
|
||||
|
||||
// 1. Create hoisted mocks FIRST
|
||||
|
||||
@@ -203,6 +203,14 @@ export class FlyerProcessingService {
|
||||
logger: Logger,
|
||||
initialStages: ProcessingStage[],
|
||||
): Promise<never> {
|
||||
// Map specific error codes to their corresponding processing stage names.
|
||||
// This is more maintainable than a long if/else if chain.
|
||||
const errorCodeToStageMap = new Map<string, string>([
|
||||
['PDF_CONVERSION_FAILED', 'Preparing Inputs'],
|
||||
['UNSUPPORTED_FILE_TYPE', 'Preparing Inputs'],
|
||||
['AI_VALIDATION_FAILED', 'Extracting Data with AI'],
|
||||
['TRANSFORMATION_FAILED', 'Transforming AI Data'], // Add new mapping
|
||||
]);
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
let errorPayload: { errorCode: string; message: string; [key: string]: any };
|
||||
let stagesToReport: ProcessingStage[] = [...initialStages]; // Create a mutable copy
|
||||
@@ -215,16 +223,15 @@ export class FlyerProcessingService {
|
||||
}
|
||||
|
||||
// Determine which stage failed
|
||||
let errorStageIndex = -1;
|
||||
const failedStageName = errorCodeToStageMap.get(errorPayload.errorCode);
|
||||
let errorStageIndex = failedStageName ? stagesToReport.findIndex(s => s.name === failedStageName) : -1;
|
||||
|
||||
// 1. Try to map specific error codes/messages to stages
|
||||
if (errorPayload.errorCode === 'PDF_CONVERSION_FAILED' || errorPayload.errorCode === 'UNSUPPORTED_FILE_TYPE') {
|
||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Preparing Inputs');
|
||||
} else if (errorPayload.errorCode === 'AI_VALIDATION_FAILED') {
|
||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Extracting Data with AI');
|
||||
} else if (errorPayload.message.includes('Icon generation failed')) {
|
||||
// Fallback for generic errors not in the map. This is less robust and relies on string matching.
|
||||
// A future improvement would be to wrap these in specific FlyerProcessingError subclasses.
|
||||
if (errorStageIndex === -1 && errorPayload.message.includes('Icon generation failed')) {
|
||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Transforming AI Data');
|
||||
} else if (errorPayload.message.includes('Database transaction failed')) {
|
||||
}
|
||||
if (errorStageIndex === -1 && errorPayload.message.includes('Database transaction failed')) {
|
||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Saving to Database');
|
||||
}
|
||||
|
||||
@@ -260,24 +267,16 @@ export class FlyerProcessingService {
|
||||
|
||||
// Logging logic
|
||||
if (normalizedError instanceof FlyerProcessingError) {
|
||||
const logDetails: Record<string, any> = { err: normalizedError };
|
||||
// Simplify log object creation
|
||||
const logDetails: Record<string, any> = { ...errorPayload, err: normalizedError };
|
||||
|
||||
if (normalizedError instanceof AiDataValidationError) {
|
||||
logDetails.validationErrors = normalizedError.validationErrors;
|
||||
logDetails.rawData = normalizedError.rawData;
|
||||
}
|
||||
// Also include stderr for PdfConversionError in logs
|
||||
if (normalizedError instanceof PdfConversionError) {
|
||||
logDetails.stderr = normalizedError.stderr;
|
||||
}
|
||||
// Include the errorPayload details in the log, but avoid duplicating err, validationErrors, rawData
|
||||
Object.assign(logDetails, errorPayload);
|
||||
// Remove the duplicated err property if it was assigned by Object.assign
|
||||
if ('err' in logDetails && logDetails.err === normalizedError) {
|
||||
// This check prevents accidental deletion if 'err' was a legitimate property of errorPayload
|
||||
delete logDetails.err;
|
||||
}
|
||||
// Ensure the original error object is always passed as 'err' for consistency in logging
|
||||
logDetails.err = normalizedError;
|
||||
|
||||
logger.error(logDetails, `A known processing error occurred: ${normalizedError.name}`);
|
||||
} else {
|
||||
|
||||
@@ -62,6 +62,18 @@ export class AiDataValidationError extends FlyerProcessingError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a transformation step fails.
|
||||
*/
|
||||
export class TransformationError extends FlyerProcessingError {
|
||||
constructor(message: string) {
|
||||
super(
|
||||
message,
|
||||
'TRANSFORMATION_FAILED',
|
||||
'There was a problem transforming the flyer data. Please check the input.',
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Error thrown when an image conversion fails (e.g., using sharp).
|
||||
*/
|
||||
|
||||
164
src/tests/e2e/auth.e2e.test.ts
Normal file
164
src/tests/e2e/auth.e2e.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/tests/e2e/auth.e2e.test.ts
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||
import type { UserProfile } from '../../types';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('Authentication E2E Flow', () => {
|
||||
let testUser: UserProfile;
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a user that can be used for login-related tests in this suite.
|
||||
const { user } = await createAndLoginUser({
|
||||
email: `e2e-login-user-${Date.now()}@example.com`,
|
||||
fullName: 'E2E Login User',
|
||||
// E2E tests use apiClient which doesn't need the `request` object.
|
||||
});
|
||||
testUser = user;
|
||||
createdUserIds.push(user.user.user_id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await cleanupDb({ userIds: createdUserIds });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Registration Flow', () => {
|
||||
it('should successfully register a new user', async () => {
|
||||
const email = `e2e-register-success-${Date.now()}@example.com`;
|
||||
const fullName = 'E2E Register User';
|
||||
|
||||
// Act
|
||||
const response = await apiClient.registerUser(email, TEST_PASSWORD, fullName);
|
||||
const data = await response.json();
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.message).toBe('User registered successfully!');
|
||||
expect(data.userprofile).toBeDefined();
|
||||
expect(data.userprofile.user.email).toBe(email);
|
||||
expect(data.token).toBeTypeOf('string');
|
||||
|
||||
// Add to cleanup
|
||||
createdUserIds.push(data.userprofile.user.user_id);
|
||||
});
|
||||
|
||||
it('should fail to register a user with a weak password', async () => {
|
||||
const email = `e2e-register-weakpass-${Date.now()}@example.com`;
|
||||
const weakPassword = '123';
|
||||
|
||||
// Act
|
||||
const response = await apiClient.registerUser(email, weakPassword, 'Weak Pass User');
|
||||
const errorData = await response.json();
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(errorData.errors[0].message).toContain('Password must be at least 8 characters long.');
|
||||
});
|
||||
|
||||
it('should fail to register a user with a duplicate email', async () => {
|
||||
const email = `e2e-register-duplicate-${Date.now()}@example.com`;
|
||||
|
||||
// Act 1: Register the user successfully
|
||||
const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
|
||||
const firstData = await firstResponse.json();
|
||||
expect(firstResponse.status).toBe(201);
|
||||
createdUserIds.push(firstData.userprofile.user.user_id); // Add for cleanup
|
||||
|
||||
// Act 2: Attempt to register the same user again
|
||||
const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
|
||||
const errorData = await secondResponse.json();
|
||||
|
||||
// Assert
|
||||
expect(secondResponse.status).toBe(409); // Conflict
|
||||
expect(errorData.message).toContain('A user with this email address already exists.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login Flow', () => {
|
||||
it('should successfully log in a registered user', async () => {
|
||||
// Act: Attempt to log in with the user created in beforeAll
|
||||
const response = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
|
||||
const data = await response.json();
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.userprofile).toBeDefined();
|
||||
expect(data.userprofile.user.email).toBe(testUser.user.email);
|
||||
expect(data.token).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('should fail to log in with an incorrect password', async () => {
|
||||
// Act: Attempt to log in with the wrong password
|
||||
const response = await apiClient.loginUser(testUser.user.email, 'wrong-password', false);
|
||||
const errorData = await response.json();
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(401);
|
||||
expect(errorData.message).toBe('Incorrect email or password.');
|
||||
});
|
||||
|
||||
it('should fail to log in with a non-existent email', async () => {
|
||||
const response = await apiClient.loginUser('no-one-here@example.com', TEST_PASSWORD, false);
|
||||
const errorData = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(errorData.message).toBe('Incorrect email or password.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Forgot/Reset Password Flow', () => {
|
||||
it('should allow a user to reset their password and log in with the new one', async () => {
|
||||
// Arrange: Create a user to reset the password for
|
||||
const email = `e2e-reset-pass-${Date.now()}@example.com`;
|
||||
const registerResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Reset Pass User');
|
||||
const registerData = await registerResponse.json();
|
||||
expect(registerResponse.status).toBe(201);
|
||||
createdUserIds.push(registerData.userprofile.user.user_id);
|
||||
|
||||
// Act 1: Request a password reset.
|
||||
// The test environment returns the token directly in the response for E2E testing.
|
||||
const forgotResponse = await apiClient.requestPasswordReset(email);
|
||||
const forgotData = await forgotResponse.json();
|
||||
const resetToken = forgotData.token;
|
||||
|
||||
// Assert 1: Check that we received a token.
|
||||
expect(forgotResponse.status).toBe(200);
|
||||
expect(resetToken).toBeDefined();
|
||||
expect(resetToken).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Use the token to set a new password.
|
||||
const newPassword = 'my-new-e2e-password-!@#$';
|
||||
const resetResponse = await apiClient.resetPassword(resetToken, newPassword);
|
||||
const resetData = await resetResponse.json();
|
||||
|
||||
// Assert 2: Check for a successful password reset message.
|
||||
expect(resetResponse.status).toBe(200);
|
||||
expect(resetData.message).toBe('Password has been reset successfully.');
|
||||
|
||||
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
|
||||
const loginResponse = await apiClient.loginUser(email, newPassword, false);
|
||||
const loginData = await loginResponse.json();
|
||||
|
||||
expect(loginResponse.status).toBe(200);
|
||||
expect(loginData.userprofile).toBeDefined();
|
||||
expect(loginData.userprofile.user.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should return a generic success message for a non-existent email to prevent enumeration', async () => {
|
||||
const nonExistentEmail = `non-existent-e2e-${Date.now()}@example.com`;
|
||||
const response = await apiClient.requestPasswordReset(nonExistentEmail);
|
||||
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.');
|
||||
expect(data.token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user