several fixes to various tests

This commit is contained in:
2025-12-27 09:10:51 -08:00
parent 768d02b9ed
commit ef4b8e58fe
10 changed files with 201 additions and 147 deletions

View File

@@ -65,8 +65,6 @@ describe('FlyerUploader', () => {
return () => {}; return () => {};
}); });
console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`); console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`);
// Use fake timers to control polling intervals.
vi.useFakeTimers();
vi.resetAllMocks(); // Resets mock implementations AND call history. vi.resetAllMocks(); // Resets mock implementations AND call history.
console.log('--- [TEST LOG] ---: Mocks reset.'); console.log('--- [TEST LOG] ---: Mocks reset.');
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum'); mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
@@ -74,7 +72,6 @@ describe('FlyerUploader', () => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`); console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`);
}); });
@@ -117,21 +114,18 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...'); console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...');
await act(async () => { // With real timers, we now wait for the polling interval to elapse.
console.log('--- [TEST LOG] ---: 8a. vi.advanceTimersByTime(3000) starting...');
vi.advanceTimersByTime(3000);
console.log('--- [TEST LOG] ---: 8b. vi.advanceTimersByTime(3000) complete.');
});
console.log( console.log(
`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`, `--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
); );
try { try {
// The polling interval is 3s, so we wait for a bit longer.
await waitFor(() => { await waitFor(() => {
const calls = mockedAiApiClient.getJobStatus.mock.calls.length; const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`); console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
}); }, { timeout: 4000 });
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.'); console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
} catch (error) { } catch (error) {
console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.'); console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
@@ -194,19 +188,16 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.'); console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.');
await act(async () => {
console.log(`--- [TEST LOG] ---: 6. Advancing timers by 4000ms for the second poll...`);
vi.advanceTimersByTime(4000);
});
console.log(`--- [TEST LOG] ---: 7. Timers advanced. Now AWAITING completion message.`);
try { try {
console.log( console.log(
'--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.', '--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
); );
// Wait for the second poll to occur and the UI to update.
await waitFor(() => { await waitFor(() => {
console.log( console.log(
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${mockedAiApiClient.getJobStatus.mock.calls.length}`, `--- [TEST LOG] ---: 8b. waitFor interval: calls=${
mockedAiApiClient.getJobStatus.mock.calls.length
}`,
); );
expect( expect(
screen.getByText('Processing complete! Redirecting to flyer 42...'), screen.getByText('Processing complete! Redirecting to flyer 42...'),
@@ -221,12 +212,9 @@ describe('FlyerUploader', () => {
} }
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
await act(async () => { // Wait for the redirect timer (1.5s in component) to fire.
console.log(`--- [TEST LOG] ---: 10. Advancing timers by 2000ms for redirect...`); await act(() => new Promise((r) => setTimeout(r, 2000)));
vi.advanceTimersByTime(2000);
});
console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`); console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`);
expect(onProcessingComplete).toHaveBeenCalled(); expect(onProcessingComplete).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42'); expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.'); console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.');
@@ -291,22 +279,16 @@ describe('FlyerUploader', () => {
// Wait for the first poll to complete and UI to update to "Working..." // Wait for the first poll to complete and UI to update to "Working..."
await screen.findByText('Working...'); await screen.findByText('Working...');
// Advance time to trigger the second poll
await act(async () => {
vi.advanceTimersByTime(3000);
});
// Wait for the failure UI // Wait for the failure UI
await screen.findByText(/Processing failed: Fatal Error/i); await waitFor(() => expect(screen.getByText(/Processing failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
// Verify clearTimeout was called // Verify clearTimeout was called
expect(clearTimeoutSpy).toHaveBeenCalled(); expect(clearTimeoutSpy).toHaveBeenCalled();
// Verify no further polling occurs // Verify no further polling occurs
const callsBefore = mockedAiApiClient.getJobStatus.mock.calls.length; const callsBefore = mockedAiApiClient.getJobStatus.mock.calls.length;
await act(async () => { // Wait for a duration longer than the polling interval
vi.advanceTimersByTime(10000); await act(() => new Promise((r) => setTimeout(r, 4000)));
});
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBefore); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBefore);
clearTimeoutSpy.mockRestore(); clearTimeoutSpy.mockRestore();

View File

@@ -150,6 +150,10 @@ describe('useAuth Hook and AuthProvider', () => {
describe('login function', () => { describe('login function', () => {
// This was the failing test // This was the failing test
it('sets token, fetches profile, and updates state on successful login', async () => { it('sets token, fetches profile, and updates state on successful login', async () => {
// --- FIX ---
// Explicitly mock that no token exists initially to prevent state leakage from other tests.
mockedTokenStorage.getToken.mockReturnValue(null);
// --- FIX --- // --- FIX ---
// The mock for `getAuthenticatedUserProfile` must resolve to a `Response`-like object, // The mock for `getAuthenticatedUserProfile` must resolve to a `Response`-like object,
// as this is the return type of the actual function. The `useApi` hook then // as this is the return type of the actual function. The `useApi` hook then
@@ -302,6 +306,10 @@ describe('useAuth Hook and AuthProvider', () => {
}); });
it('should not update profile if user is not authenticated', async () => { it('should not update profile if user is not authenticated', async () => {
// --- FIX ---
// Explicitly mock that no token exists initially to prevent state leakage from other tests.
mockedTokenStorage.getToken.mockReturnValue(null);
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
// Wait for initial check to complete // Wait for initial check to complete

View File

@@ -0,0 +1,119 @@
// src/middleware/multer.middleware.ts
import multer from 'multer';
import path from 'path';
import fs from 'node:fs/promises';
import { Request, Response, NextFunction } from 'express';
import { UserProfile } from '../types';
import { sanitizeFilename } from '../utils/stringUtils';
import { logger } from '../services/logger.server';
export const flyerStoragePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
export const avatarStoragePath = path.join(process.cwd(), 'public', 'uploads', 'avatars');
// Ensure directories exist at startup
(async () => {
try {
await fs.mkdir(flyerStoragePath, { recursive: true });
await fs.mkdir(avatarStoragePath, { recursive: true });
logger.info('Ensured multer storage directories exist.');
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error({ error: err }, 'Failed to create multer storage directories on startup.');
}
})();
type StorageType = 'flyer' | 'avatar';
const getStorageConfig = (type: StorageType) => {
switch (type) {
case 'avatar':
return multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarStoragePath),
filename: (req, file, cb) => {
const user = req.user as UserProfile | undefined;
if (!user) {
// This should ideally not happen if auth middleware runs first.
return cb(new Error('User not authenticated for avatar upload'), '');
}
const uniqueSuffix = `${user.user.user_id}-${Date.now()}${path.extname(
file.originalname,
)}`;
cb(null, uniqueSuffix);
},
});
case 'flyer':
default:
return multer.diskStorage({
destination: (req, file, cb) => cb(null, flyerStoragePath),
filename: (req, file, cb) => {
if (process.env.NODE_ENV === 'test') {
// Use a predictable filename for test flyers for easy cleanup.
const ext = path.extname(file.originalname);
return cb(null, `${file.fieldname}-test-flyer-image${ext || '.jpg'}`);
}
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const sanitizedOriginalName = sanitizeFilename(file.originalname);
cb(null, `${file.fieldname}-${uniqueSuffix}-${sanitizedOriginalName}`);
},
});
}
};
const imageFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
// Reject the file with a specific error that can be caught by a middleware.
const err = new Error('Only image files are allowed!');
cb(err);
}
};
interface MulterOptions {
storageType: StorageType;
fileSize?: number;
fileFilter?: 'image';
}
/**
* Creates a configured multer instance for file uploads.
* @param options - Configuration for storage type, file size, and file filter.
* @returns A multer instance.
*/
export const createUploadMiddleware = (options: MulterOptions) => {
const multerOptions: multer.Options = {
storage: getStorageConfig(options.storageType),
};
if (options.fileSize) {
multerOptions.limits = { fileSize: options.fileSize };
}
if (options.fileFilter === 'image') {
multerOptions.fileFilter = imageFileFilter;
}
return multer(multerOptions);
};
/**
* A general error handler for multer. Place this after all routes using multer in your router file.
* It catches errors from `fileFilter` and other multer issues (e.g., file size limits).
*/
export const handleMulterError = (
err: Error,
req: Request,
res: Response,
next: NextFunction,
) => {
if (err instanceof multer.MulterError) {
// A Multer error occurred when uploading (e.g., file too large).
return res.status(400).json({ message: `File upload error: ${err.message}` });
} else if (err && err.message === 'Only image files are allowed!') {
// A custom error from our fileFilter.
return res.status(400).json({ message: err.message });
}
// If it's not a multer error, pass it on.
next(err);
};

View File

@@ -436,7 +436,9 @@ describe('ProfileManager', () => {
}); });
}); });
it('should automatically geocode address after user stops typing', async () => { it('should automatically geocode address after user stops typing (using fake timers)', async () => {
// Use real timers for the initial async render and data fetch
vi.useRealTimers();
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockedApiClient.getUserAddress.mockResolvedValue( mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)), new Response(JSON.stringify(addressWithoutCoords)),
@@ -454,13 +456,19 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } }); fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled(); expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
console.log('[TEST LOG] Waiting 1600ms for debounce...'); // Switch to fake timers to control the debounce timeout
// Wait for debounce (1500ms) + buffer using real timers to avoid freeze vi.useFakeTimers();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1600)); // Advance timers to trigger the debounced function
act(() => {
vi.advanceTimersByTime(1500); // Must match debounce delay in useProfileAddress
}); });
console.log('[TEST LOG] Wait complete. Checking results.'); console.log('[TEST LOG] Wait complete. Checking results.');
// Switch back to real timers to allow the async geocodeAddress promise to resolve
vi.useRealTimers();
// Now wait for the UI to update after the promise resolves
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith( expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
expect.stringContaining('NewCity'), expect.stringContaining('NewCity'),
@@ -470,17 +478,19 @@ describe('ProfileManager', () => {
}); });
}); });
it('should not geocode if address already has coordinates', async () => { it('should not geocode if address already has coordinates (using fake timers)', async () => {
console.log('[TEST LOG] Rendering for no-geocode test (Real Timers + Wait)'); // Use real timers for the initial async render and data fetch
vi.useRealTimers();
render(<ProfileManager {...defaultAuthenticatedProps} />); render(<ProfileManager {...defaultAuthenticatedProps} />);
console.log('[TEST LOG] Waiting for initial address load...'); console.log('[TEST LOG] Waiting for initial address load...');
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown')); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
console.log( // Switch to fake timers to control the debounce check
'[TEST LOG] Initial address loaded. Waiting 1600ms to ensure no geocode triggers...', vi.useFakeTimers();
);
await act(async () => { // Advance timers past the debounce threshold. Nothing should happen.
await new Promise((resolve) => setTimeout(resolve, 1600)); act(() => {
vi.advanceTimersByTime(1600);
}); });
console.log('[TEST LOG] Wait complete. Verifying no geocode call.'); console.log('[TEST LOG] Wait complete. Verifying no geocode call.');
@@ -524,7 +534,7 @@ describe('ProfileManager', () => {
expect(await screen.findByRole('heading', { name: /theme/i })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: /theme/i })).toBeInTheDocument();
// Switch back to Profile // Switch back to Profile
fireEvent.click(screen.getByRole('button', { name: /profile/i })); fireEvent.click(screen.getByRole('button', { name: /^profile$/i }));
expect(await screen.findByLabelText('Profile Form')).toBeInTheDocument(); expect(await screen.findByLabelText('Profile Form')).toBeInTheDocument();
}); });
@@ -735,9 +745,7 @@ describe('ProfileManager', () => {
}); });
it('should handle account deletion flow', async () => { it('should handle account deletion flow', async () => {
// Use fake timers to test the setTimeout call render(<ProfileManager {...defaultAuthenticatedProps} />);
vi.useFakeTimers();
const { unmount } = render(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i })); fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
@@ -767,15 +775,16 @@ describe('ProfileManager', () => {
); );
}); });
// Now, switch to fake timers to control the setTimeout.
vi.useFakeTimers();
// Advance timers to trigger the logout and close // Advance timers to trigger the logout and close
act(() => { act(() => {
vi.advanceTimersByTime(3000); vi.advanceTimersByTime(3000);
}); });
await waitFor(() => { expect(mockOnClose).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnSignOut).toHaveBeenCalled();
expect(mockOnSignOut).toHaveBeenCalled();
});
}); });
it('should allow toggling dark mode', async () => { it('should allow toggling dark mode', async () => {

View File

@@ -244,7 +244,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body.message).toBe('Brand logo updated successfully.'); expect(response.body.message).toBe('Brand logo updated successfully.');
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith( expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
brandId, brandId,
expect.stringContaining('/assets/'), expect.stringContaining('/flyer-images/'),
expect.anything(), expect.anything(),
); );
}); });

View File

@@ -2,7 +2,6 @@
import { Router, NextFunction, Request, Response } from 'express'; import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes'; import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported import { isAdmin } from './passport.routes'; // Correctly imported
import fs from 'node:fs/promises';
import multer from 'multer'; import multer from 'multer';
import { z } from 'zod'; import { z } from 'zod';
@@ -10,6 +9,10 @@ import * as db from '../services/db/index.db';
import type { UserProfile } from '../types'; import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server'; import { geocodingService } from '../services/geocodingService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed. import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { NotFoundError, ValidationError } from '../services/db/errors.db'; import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
@@ -42,6 +45,7 @@ import {
optionalNumeric, optionalNumeric,
} from '../utils/zodUtils'; } from '../utils/zodUtils';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import fs from 'node:fs/promises';
/** /**
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist. * Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
@@ -102,19 +106,7 @@ const jobRetrySchema = z.object({
const router = Router(); const router = Router();
// --- Multer Configuration for File Uploads --- const upload = createUploadMiddleware({ storageType: 'flyer' });
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
},
});
const upload = multer({ storage: storage });
// --- Bull Board (Job Queue UI) Setup --- // --- Bull Board (Job Queue UI) Setup ---
const serverAdapter = new ExpressAdapter(); const serverAdapter = new ExpressAdapter();
@@ -698,4 +690,7 @@ router.post(
}, },
); );
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router; export default router;

View File

@@ -1,6 +1,5 @@
// src/routes/ai.routes.ts // src/routes/ai.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'node:fs'; import fs from 'node:fs';
import { z } from 'zod'; import { z } from 'zod';
@@ -9,8 +8,11 @@ import { optionalAuth } from './passport.routes';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { createFlyerAndItems } from '../services/db/flyer.db'; import { createFlyerAndItems } from '../services/db/flyer.db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { generateFlyerIcon } from '../utils/imageProcessor'; import { generateFlyerIcon } from '../utils/imageProcessor';
import { sanitizeFilename } from '../utils/stringUtils';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types'; import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
import { flyerQueue } from '../services/queueService.server'; import { flyerQueue } from '../services/queueService.server';
@@ -154,40 +156,7 @@ const searchWebSchema = z.object({
body: z.object({ query: requiredString('A search query is required.') }), body: z.object({ query: requiredString('A search query is required.') }),
}); });
// --- Multer Configuration for File Uploads --- const uploadToDisk = createUploadMiddleware({ storageType: 'flyer' });
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
// Ensure the storage path exists at startup so multer can write files there.
try {
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
} catch (err) {
logger.error(
{ error: errMsg(err) },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
// If in a test environment, use a predictable filename for easy cleanup.
if (process.env.NODE_ENV === 'test') {
return cb(null, `${file.fieldname}-test-flyer-image.jpg`);
} else {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
// Sanitize the original filename to remove spaces and special characters
return cb(
null,
file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname),
);
}
},
});
const uploadToDisk = multer({ storage: diskStorage });
// Diagnostic middleware: log incoming AI route requests (headers and sizes) // Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => { router.use((req: Request, res: Response, next: NextFunction) => {
@@ -722,4 +691,7 @@ router.post(
}, },
); );
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router; export default router;

View File

@@ -53,7 +53,7 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
* This is important for features like file uploads. * This is important for features like file uploads.
*/ */
router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => { router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets'; const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
try { try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res return res

View File

@@ -1,13 +1,16 @@
// src/routes/user.routes.ts // src/routes/user.routes.ts
import express, { Request, Response, NextFunction } from 'express'; import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes'; import passport from './passport.routes';
import multer from 'multer'; import multer from 'multer'; // Keep for MulterError type check
import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed. import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed.
import { z } from 'zod'; import { z } from 'zod';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { UserProfile } from '../types'; import { UserProfile } from '../types';
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { userService } from '../services/userService'; import { userService } from '../services/userService';
import { ForeignKeyConstraintError } from '../services/db/errors.db'; import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
@@ -85,35 +88,10 @@ const emptySchema = z.object({});
// Any request to a /api/users/* endpoint will now require a valid JWT. // Any request to a /api/users/* endpoint will now require a valid JWT.
router.use(passport.authenticate('jwt', { session: false })); router.use(passport.authenticate('jwt', { session: false }));
// --- Multer Configuration for Avatar Uploads --- const avatarUpload = createUploadMiddleware({
storageType: 'avatar',
// Ensure the directory for avatar uploads exists. fileSize: 1 * 1024 * 1024, // 1MB
const avatarUploadDir = path.join(process.cwd(), 'public', 'uploads', 'avatars'); fileFilter: 'image',
fs.mkdir(avatarUploadDir, { recursive: true }).catch((err) => {
logger.error({ err }, 'Failed to create avatar upload directory');
});
// Define multer storage configuration. The `req.user` object will be available
// here because the passport middleware runs before this route handler.
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarUploadDir),
filename: (req, file, cb) => {
const uniqueSuffix = `${(req.user as UserProfile).user.user_id}-${Date.now()}${path.extname(file.originalname)}`;
cb(null, uniqueSuffix);
},
});
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB file size limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
// Reject the file with a specific error
cb(new Error('Only image files are allowed!'));
}
},
}); });
/** /**
@@ -857,18 +835,7 @@ router.put(
}, },
); );
// --- General Multer Error Handler --- /* Catches errors from multer (e.g., file size, file filter) */
// This should be placed after all routes that use multer. router.use(handleMulterError);
// It catches errors from `fileFilter` and other multer issues (e.g., file size limits).
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof multer.MulterError) {
// A Multer error occurred when uploading (e.g., file too large).
return res.status(400).json({ message: `File upload error: ${err.message}` });
} else if (err && err.message === 'Only image files are allowed!') {
// A custom error from our fileFilter.
return res.status(400).json({ message: err.message });
}
next(err); // Pass on to the next error handler if it's not a multer error we handle.
});
export default router; export default router;

View File

@@ -44,10 +44,12 @@ export const uploadAndProcessFlyer = async (
if (!response.ok) { if (!response.ok) {
let errorBody; let errorBody;
// Clone the response so we can read the body twice (once as JSON, and as text on failure).
const clonedResponse = response.clone();
try { try {
errorBody = await response.json(); errorBody = await response.json();
} catch (e) { } catch (e) {
errorBody = { message: await response.text() }; errorBody = { message: await clonedResponse.text() };
} }
// Throw a structured error so the component can inspect the status and body // Throw a structured error so the component can inspect the status and body
throw { status: response.status, body: errorBody }; throw { status: response.status, body: errorBody };