Compare commits

...

5 Commits

Author SHA1 Message Date
Gitea Actions
c4bbf5c251 ci: Bump version to 0.1.6 [skip ci] 2025-12-25 07:19:39 +05:00
32a9e6732b Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3h0m53s
2025-12-24 18:18:35 -08:00
e7c076e2ed test repair 2025-12-24 18:18:28 -08:00
Gitea Actions
dbe8e72efe ci: Bump version to 0.1.5 [skip ci] 2025-12-25 06:13:16 +05:00
38bd193042 not sure why those errors got removed we'll see
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-24 17:12:20 -08:00
7 changed files with 61 additions and 40 deletions

4
package-lock.json generated
View File

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

View File

@@ -1,7 +1,12 @@
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { NotFoundError, UniqueConstraintError, ValidationError } from '../services/db/errors.db';
import {
ForeignKeyConstraintError,
NotFoundError,
UniqueConstraintError,
ValidationError,
} from '../services/db/errors.db';
import { logger } from '../services/logger.server';
/**
@@ -24,7 +29,7 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
log.warn({ err: err.flatten() }, 'Request validation failed');
return res.status(400).json({
message: 'The request data is invalid.',
errors: err.errors.map((e) => ({ path: e.path, message: e.message })),
errors: err.issues.map((e) => ({ path: e.path, message: e.message })),
});
}
@@ -34,8 +39,18 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
return res.status(404).json({ message: err.message });
}
if (err instanceof UniqueConstraintError || err instanceof ValidationError) {
log.warn({ err }, 'Constraint or validation error occurred');
if (err instanceof ValidationError) {
log.warn({ err }, 'Validation error occurred');
return res.status(400).json({ message: err.message, errors: err.validationErrors });
}
if (err instanceof UniqueConstraintError) {
log.warn({ err }, 'Constraint error occurred');
return res.status(409).json({ message: err.message }); // Use 409 Conflict for unique constraints
}
if (err instanceof ForeignKeyConstraintError) {
log.warn({ err }, 'Foreign key constraint violation');
return res.status(400).json({ message: err.message });
}

View File

@@ -176,15 +176,13 @@ describe('API Client', () => {
// We expect the promise to still resolve with the bad response, but log an error.
await apiClient.apiFetch('/some/failing/endpoint');
// FIX: Use stringContaining to be resilient to port numbers (e.g., localhost:3001)
// This checks for the essential parts of the log message without being brittle.
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('apiFetch: Request to http://'),
'Internal Server Error',
);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('/api/some/failing/endpoint failed with status 500'),
'Internal Server Error',
expect.objectContaining({
status: 500,
body: 'Internal Server Error',
url: expect.stringContaining('/some/failing/endpoint'),
}),
'apiFetch: Request failed',
);
});
@@ -242,10 +240,6 @@ describe('API Client', () => {
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
});
it('logSearchQuery should log a warning on failure', async () => {
@@ -259,8 +253,6 @@ describe('API Client', () => {
was_successful: false,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
});
});

View File

@@ -87,7 +87,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(
{ err: 'Redis down', cacheKey: expect.any(String) },
{ err: expect.any(Error), cacheKey: expect.any(String) },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
expect(mockGoogleService.geocode).toHaveBeenCalled(); // Should still proceed to fetch
@@ -107,7 +107,7 @@ describe('Geocoding Service', () => {
expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
// The service should log the JSON parsing error and continue
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(String), cacheKey: expect.any(String) },
{ err: expect.any(SyntaxError), cacheKey: expect.any(String) },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
@@ -185,7 +185,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(
{ err: 'Network Error' },
{ err: expect.any(Error) },
expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'),
);
expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
@@ -223,7 +223,7 @@ describe('Geocoding Service', () => {
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
{ err: 'Redis SET failed', cacheKey: expect.any(String) },
{ err: expect.any(Error), cacheKey: expect.any(String) },
'Redis SET command failed. Result will not be cached.',
);
});
@@ -271,7 +271,7 @@ describe('Geocoding Service', () => {
// Act & Assert
await expect(geocodingService.clearGeocodeCache(logger)).rejects.toThrow(redisError);
expect(logger.error).toHaveBeenCalledWith(
{ err: redisError.message },
{ err: expect.any(Error) },
'Failed to clear geocode cache from Redis.',
);
expect(mocks.mockRedis.del).not.toHaveBeenCalled();

View File

@@ -164,6 +164,14 @@ const flyerProcessingService = new FlyerProcessingService(
new FlyerDataTransformer(), // Inject the new transformer
);
/**
* Helper to ensure that an unknown error is normalized to an Error object.
* This ensures consistent logging structure and stack traces.
*/
const normalizeError = (error: unknown): Error => {
return error instanceof Error ? error : new Error(String(error));
};
/**
* A generic function to attach logging event listeners to any worker.
* This centralizes logging for job completion and final failure.
@@ -189,16 +197,17 @@ export const flyerWorker = new Worker<FlyerJobData>(
try {
// The processJob method creates its own job-specific logger internally.
return await flyerProcessingService.processJob(job);
} catch (error: any) {
} catch (error: unknown) {
const wrappedError = normalizeError(error);
// Check for quota errors or other unrecoverable errors from the AI service
const errorMessage = error?.message || '';
const errorMessage = wrappedError.message || '';
if (
errorMessage.includes('quota') ||
errorMessage.includes('429') ||
errorMessage.includes('RESOURCE_EXHAUSTED')
) {
logger.error(
{ err: error, jobId: job.id },
{ err: wrappedError, jobId: job.id },
'[FlyerWorker] Unrecoverable quota error detected. Failing job immediately.',
);
throw new UnrecoverableError(errorMessage);
@@ -224,15 +233,16 @@ export const emailWorker = new Worker<EmailJobData>(
try {
await emailService.sendEmail(job.data, jobLogger);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
logger.error(
{
err: error,
err: wrappedError,
jobData: job.data,
},
`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
// Re-throw to let BullMQ handle the failure and retry.
throw error;
throw wrappedError;
}
},
{
@@ -261,11 +271,12 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
await new Promise((resolve) => setTimeout(resolve, 10000)); // Simulate a 10-second task
logger.info(`[AnalyticsWorker] Successfully generated report for ${reportDate}.`);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
// Standardize error logging.
logger.error({ err: error, jobData: job.data },
logger.error({ err: wrappedError, jobData: job.data },
`[AnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw error; // Re-throw to let BullMQ handle the failure and retry.
throw wrappedError; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
@@ -322,12 +333,13 @@ export const cleanupWorker = new Worker<CleanupJobData>(
`[CleanupWorker] Successfully cleaned up ${paths.length} file(s) for flyer ${flyerId}.`,
);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
// Standardize error logging.
logger.error(
{ err: error },
{ err: wrappedError },
`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw error; // Re-throw to let BullMQ handle the failure and retry.
throw wrappedError; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
@@ -355,12 +367,13 @@ export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
`[WeeklyAnalyticsWorker] Successfully generated weekly report for week ${reportWeek}, ${reportYear}.`,
);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
// Standardize error logging.
logger.error(
{ err: error, jobData: job.data },
{ err: wrappedError, jobData: job.data },
`[WeeklyAnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw error; // Re-throw to let BullMQ handle the failure and retry.
throw wrappedError; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
@@ -382,8 +395,9 @@ export const tokenCleanupWorker = new Worker<TokenCleanupJobData>(
jobLogger.info(`[TokenCleanupWorker] Successfully deleted ${deletedCount} expired tokens.`);
return { deletedCount };
} catch (error: unknown) {
jobLogger.error({ err: error }, `[TokenCleanupWorker] Job ${job.id} failed.`);
throw error;
const wrappedError = normalizeError(error);
jobLogger.error({ err: wrappedError }, `[TokenCleanupWorker] Job ${job.id} failed.`);
throw wrappedError;
}
},
{

View File

@@ -175,7 +175,7 @@ describe('Queue Workers', () => {
const emailError = 'SMTP server is down'; // Reject with a string
mocks.sendEmail.mockRejectedValue(emailError);
await expect(emailProcessor(job)).rejects.toBe(emailError);
await expect(emailProcessor(job)).rejects.toThrow(emailError);
// The worker should wrap the string in an Error object for logging
expect(mockLogger.error).toHaveBeenCalledWith(