Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0490f6922e | ||
| 057c4c9174 | |||
|
|
a9e56bc707 | ||
| e5d09c73b7 | |||
|
|
6e1298b825 | ||
| fc8e43437a |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.17",
|
"version": "0.9.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.17",
|
"version": "0.9.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.17",
|
"version": "0.9.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ vi.mock('./components/Header', async () => {
|
|||||||
return { Header: MockHeader };
|
return { Header: MockHeader };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./pages/HomePage', async () => {
|
vi.mock('./pages/HomePage', () => ({
|
||||||
const { MockHomePage } = await import('./tests/utils/componentMocks');
|
HomePage: (props: any) => (
|
||||||
return { HomePage: MockHomePage };
|
<div data-testid="home-page-mock" data-selected-flyer-id={props.selectedFlyer?.flyer_id}>
|
||||||
});
|
Mock Home Page
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminPage', async () => {
|
vi.mock('./pages/admin/AdminPage', async () => {
|
||||||
const { MockAdminPage } = await import('./tests/utils/componentMocks');
|
const { MockAdminPage } = await import('./tests/utils/componentMocks');
|
||||||
@@ -361,12 +364,8 @@ describe('App Component', () => {
|
|||||||
it('should select a flyer when flyerId is present in the URL', async () => {
|
it('should select a flyer when flyerId is present in the URL', async () => {
|
||||||
renderApp(['/flyers/2']);
|
renderApp(['/flyers/2']);
|
||||||
|
|
||||||
// The HomePage mock will be rendered. The important part is that the selection logic
|
|
||||||
// in App.tsx runs and passes the correct `selectedFlyer` prop down.
|
|
||||||
// Since HomePage is mocked, we can't see the direct result, but we can
|
|
||||||
// infer that the logic ran without crashing and the correct route was matched.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('home-page-mock')).toHaveAttribute('data-selected-flyer-id', '2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -1,6 +1,6 @@
|
|||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Routes, Route, useParams } from 'react-router-dom';
|
import { Routes, Route, useLocation, matchPath } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
@@ -45,7 +45,9 @@ function App() {
|
|||||||
const { flyers } = useFlyers();
|
const { flyers } = useFlyers();
|
||||||
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
||||||
const { openModal, closeModal, isModalOpen } = useModal();
|
const { openModal, closeModal, isModalOpen } = useModal();
|
||||||
const params = useParams<{ flyerId?: string }>();
|
const location = useLocation();
|
||||||
|
const match = matchPath('/flyers/:flyerId', location.pathname);
|
||||||
|
const flyerIdFromUrl = match?.params.flyerId;
|
||||||
|
|
||||||
// This hook now handles initialization effects (OAuth, version check, theme)
|
// This hook now handles initialization effects (OAuth, version check, theme)
|
||||||
// and returns the theme/unit state needed by other components.
|
// and returns the theme/unit state needed by other components.
|
||||||
@@ -57,7 +59,7 @@ function App() {
|
|||||||
console.log('[App] Render:', {
|
console.log('[App] Render:', {
|
||||||
flyersCount: flyers.length,
|
flyersCount: flyers.length,
|
||||||
selectedFlyerId: selectedFlyer?.flyer_id,
|
selectedFlyerId: selectedFlyer?.flyer_id,
|
||||||
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
|
flyerIdFromUrl,
|
||||||
authStatus,
|
authStatus,
|
||||||
profileId: userProfile?.user.user_id,
|
profileId: userProfile?.user.user_id,
|
||||||
});
|
});
|
||||||
@@ -139,8 +141,6 @@ function App() {
|
|||||||
|
|
||||||
// New effect to handle routing to a specific flyer ID from the URL
|
// New effect to handle routing to a specific flyer ID from the URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const flyerIdFromUrl = params.flyerId;
|
|
||||||
|
|
||||||
if (flyerIdFromUrl && flyers.length > 0) {
|
if (flyerIdFromUrl && flyers.length > 0) {
|
||||||
const flyerId = parseInt(flyerIdFromUrl, 10);
|
const flyerId = parseInt(flyerIdFromUrl, 10);
|
||||||
const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId);
|
const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId);
|
||||||
@@ -148,7 +148,7 @@ function App() {
|
|||||||
handleFlyerSelect(flyerToSelect);
|
handleFlyerSelect(flyerToSelect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [flyers, handleFlyerSelect, selectedFlyer, params.flyerId]);
|
}, [flyers, handleFlyerSelect, selectedFlyer, flyerIdFromUrl]);
|
||||||
|
|
||||||
// Read the application version injected at build time.
|
// Read the application version injected at build time.
|
||||||
// This will only be available in the production build, not during local development.
|
// This will only be available in the production build, not during local development.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe('AchievementsList', () => {
|
|||||||
points_value: 15,
|
points_value: 15,
|
||||||
}),
|
}),
|
||||||
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
||||||
|
createMockUserAchievement({ achievement_id: 4, name: 'No Icon Achievement', icon: '' }), // Triggers the fallback for missing name
|
||||||
];
|
];
|
||||||
|
|
||||||
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
|
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
|
||||||
@@ -41,7 +42,15 @@ describe('AchievementsList', () => {
|
|||||||
|
|
||||||
// Check achievement with default icon
|
// Check achievement with default icon
|
||||||
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
|
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
|
||||||
expect(screen.getByText('🏆')).toBeInTheDocument(); // Default icon
|
// We expect at least one trophy (for unknown achievement).
|
||||||
|
// Since we added another one that produces a trophy (No Icon), we use getAllByText.
|
||||||
|
expect(screen.getAllByText('🏆').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check achievement with missing icon (empty string)
|
||||||
|
expect(screen.getByText('No Icon Achievement')).toBeInTheDocument();
|
||||||
|
// Verify the specific placeholder class is rendered, ensuring the early return in Icon component is hit
|
||||||
|
const noIconCard = screen.getByText('No Icon Achievement').closest('.bg-white');
|
||||||
|
expect(noIconCard?.querySelector('.icon-placeholder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a message when there are no achievements', () => {
|
it('should render a message when there are no achievements', () => {
|
||||||
|
|||||||
@@ -252,4 +252,54 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle API failure response (ok: false) correctly', async () => {
|
||||||
|
console.log('TEST: Starting "should handle API failure response (ok: false) correctly"');
|
||||||
|
mockedAiApiClient.rescanImageArea.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Custom API Error' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
|
// Wait for image fetch
|
||||||
|
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Draw selection
|
||||||
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||||
|
fireEvent.mouseMove(canvas, { clientX: 50, clientY: 50 });
|
||||||
|
fireEvent.mouseUp(canvas);
|
||||||
|
|
||||||
|
// Click extract
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedNotifyError).toHaveBeenCalledWith('Custom API Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redraw the canvas when the image loads', () => {
|
||||||
|
console.log('TEST: Starting "should redraw the canvas when the image loads"');
|
||||||
|
const clearRectSpy = vi.fn();
|
||||||
|
// Override the getContext mock for this test to capture the spy
|
||||||
|
window.HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
|
||||||
|
clearRect: clearRectSpy,
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
setLineDash: vi.fn(),
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 0,
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
const image = screen.getByAltText('Flyer for correction');
|
||||||
|
|
||||||
|
// The draw function is called on mount via useEffect, so we clear that call.
|
||||||
|
clearRectSpy.mockClear();
|
||||||
|
|
||||||
|
// Simulate image load event which triggers onLoad={draw}
|
||||||
|
fireEvent.load(image);
|
||||||
|
|
||||||
|
expect(clearRectSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,4 +153,50 @@ describe('RecipeSuggester Component', () => {
|
|||||||
});
|
});
|
||||||
console.log('TEST: Previous error cleared successfully');
|
console.log('TEST: Previous error cleared successfully');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses default error message when API error response has no message', async () => {
|
||||||
|
console.log('TEST: Verifying default error message for API failure');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'mystery');
|
||||||
|
|
||||||
|
// Mock API failure response without a message property
|
||||||
|
mockedApiClient.suggestRecipe.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({}), // Empty object
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to get suggestion.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-Error objects thrown during fetch', async () => {
|
||||||
|
console.log('TEST: Verifying handling of non-Error exceptions');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'chaos');
|
||||||
|
|
||||||
|
// Mock a rejection that is NOT an Error object
|
||||||
|
mockedApiClient.suggestRecipe.mockRejectedValue('Something weird happened');
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: 'Something weird happened' },
|
||||||
|
'Failed to fetch recipe suggestion.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -77,6 +77,18 @@ describe('PriceChart', () => {
|
|||||||
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render an error message when an error occurs', () => {
|
||||||
|
mockedUseActiveDeals.mockReturnValue({
|
||||||
|
...mockedUseActiveDeals(),
|
||||||
|
activeDeals: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to fetch deals.',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PriceChart {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Failed to fetch deals.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render the table with deal items when data is provided', () => {
|
it('should render the table with deal items when data is provided', () => {
|
||||||
render(<PriceChart {...defaultProps} />);
|
render(<PriceChart {...defaultProps} />);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ interface TopDealsProps {
|
|||||||
|
|
||||||
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
|
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
|
||||||
const topDeals = useMemo(() => {
|
const topDeals = useMemo(() => {
|
||||||
|
// Use a type guard in the filter to inform TypeScript that price_in_cents is non-null
|
||||||
|
// in subsequent operations. This allows removing the redundant nullish coalescing in sort.
|
||||||
return [...items]
|
return [...items]
|
||||||
.filter((item) => item.price_in_cents !== null) // Only include items with a parseable price
|
.filter(
|
||||||
.sort((a, b) => (a.price_in_cents ?? Infinity) - (b.price_in_cents ?? Infinity))
|
(item): item is FlyerItem & { price_in_cents: number } => item.price_in_cents !== null,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.price_in_cents - b.price_in_cents)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vites
|
|||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
||||||
|
import { DatabaseError } from '../services/processingErrors';
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -69,7 +69,7 @@ app.get('/unique-error', (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/db-error-500', (req, res, next) => {
|
app.get('/db-error-500', (req, res, next) => {
|
||||||
next(new DatabaseError('A database connection issue occurred.', 500));
|
next(new DatabaseError('A database connection issue occurred.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/unauthorized-error-no-status', (req, res, next) => {
|
app.get('/unauthorized-error-no-status', (req, res, next) => {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe('FlyerReviewPage', () => {
|
|||||||
file_name: 'flyer3.jpg',
|
file_name: 'flyer3.jpg',
|
||||||
created_at: '2023-01-03T00:00:00Z',
|
created_at: '2023-01-03T00:00:00Z',
|
||||||
store: null,
|
store: null,
|
||||||
icon_url: 'http://example.com/icon2.jpg',
|
icon_url: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ describe('FlyerReviewPage', () => {
|
|||||||
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
|
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
|
||||||
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
|
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
|
||||||
expect(unknownStoreImage).not.toHaveAttribute('src');
|
expect(unknownStoreImage).not.toHaveAttribute('src');
|
||||||
expect(unknownStoreImage).not.toHaveAttribute('alt');
|
expect(unknownStoreImage).toHaveAttribute('alt', 'Unknown Store');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders error message when API response is not ok', async () => {
|
it('renders error message when API response is not ok', async () => {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const FlyerReviewPage: React.FC = () => {
|
|||||||
flyers.map((flyer) => (
|
flyers.map((flyer) => (
|
||||||
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
||||||
<img src={flyer.icon_url || undefined} alt={flyer.store?.name} className="w-12 h-12 rounded-md object-cover" />
|
<img src={flyer.icon_url || undefined} alt={flyer.store?.name || 'Unknown Store'} className="w-12 h-12 rounded-md object-cover" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ import { logger as mockLoggerInstance } from './logger.server';
|
|||||||
// Explicitly unmock the service under test to ensure we import the real implementation.
|
// Explicitly unmock the service under test to ensure we import the real implementation.
|
||||||
vi.unmock('./aiService.server');
|
vi.unmock('./aiService.server');
|
||||||
|
|
||||||
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => {
|
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity } = vi.hoisted(() => {
|
||||||
const mockGenerateContent = vi.fn();
|
const mockGenerateContent = vi.fn();
|
||||||
const mockToBuffer = vi.fn();
|
const mockToBuffer = vi.fn();
|
||||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||||
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp };
|
const mockAdminLogActivity = vi.fn();
|
||||||
|
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock sharp, as it's a direct dependency of the service.
|
// Mock sharp, as it's a direct dependency of the service.
|
||||||
@@ -65,6 +66,7 @@ vi.mock('./db/index.db', () => ({
|
|||||||
adminRepo: {
|
adminRepo: {
|
||||||
logActivity: vi.fn(),
|
logActivity: vi.fn(),
|
||||||
},
|
},
|
||||||
|
withTransaction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./queueService.server', () => ({
|
vi.mock('./queueService.server', () => ({
|
||||||
@@ -81,10 +83,17 @@ vi.mock('../utils/imageProcessor', () => ({
|
|||||||
generateFlyerIcon: vi.fn(),
|
generateFlyerIcon: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./db/admin.db', () => ({
|
||||||
|
AdminRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
logActivity: mockAdminLogActivity,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import mocked modules to assert on them
|
// Import mocked modules to assert on them
|
||||||
import * as dbModule from './db/index.db';
|
import * as dbModule from './db/index.db';
|
||||||
import { flyerQueue } from './queueService.server';
|
import { flyerQueue } from './queueService.server';
|
||||||
import { createFlyerAndItems } from './db/flyer.db';
|
import { createFlyerAndItems } from './db/flyer.db';
|
||||||
|
import { withTransaction } from './db/index.db';
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
|
|
||||||
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
|
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
|
||||||
@@ -121,12 +130,16 @@ describe('AI Service (Server)', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGenerateContent.mockReset();
|
mockGenerateContent.mockReset();
|
||||||
|
mockAdminLogActivity.mockClear();
|
||||||
// Reset modules to ensure the service re-initializes with the mocks
|
// Reset modules to ensure the service re-initializes with the mocks
|
||||||
|
|
||||||
mockAiClient.generateContent.mockResolvedValue({
|
mockAiClient.generateContent.mockResolvedValue({
|
||||||
text: '[]',
|
text: '[]',
|
||||||
candidates: [],
|
candidates: [],
|
||||||
});
|
});
|
||||||
|
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
||||||
|
return callback({}); // Mock client
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AiFlyerDataSchema', () => {
|
describe('AiFlyerDataSchema', () => {
|
||||||
@@ -336,8 +349,6 @@ describe('AI Service (Server)', () => {
|
|||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
{ error: nonRetriableError }, // The first model in the list is now 'gemini-2.5-flash'
|
{ error: nonRetriableError }, // The first model in the list is now 'gemini-2.5-flash'
|
||||||
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
||||||
{ error: nonRetriableError }, // The first model in the list
|
|
||||||
`[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -616,11 +627,8 @@ describe('AI Service (Server)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
|
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
|
||||||
expect(result.store_name).toBe('Test Store');
|
// With normalization removed from this service, the result should match the raw AI response.
|
||||||
expect(result.items).toHaveLength(2);
|
expect(result).toEqual(mockAiResponse);
|
||||||
expect(result.items[1].price_display).toBe('');
|
|
||||||
expect(result.items[1].quantity).toBe('');
|
|
||||||
expect(result.items[1].category_name).toBe('Other/Miscellaneous');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if the AI response is not a valid JSON object', async () => {
|
it('should throw an error if the AI response is not a valid JSON object', async () => {
|
||||||
@@ -1117,6 +1125,7 @@ describe('AI Service (Server)', () => {
|
|||||||
}),
|
}),
|
||||||
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
|
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1143,6 +1152,7 @@ describe('AI Service (Server)', () => {
|
|||||||
}),
|
}),
|
||||||
[], // No items
|
[], // No items
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1174,6 +1184,7 @@ describe('AI Service (Server)', () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('extractedData.store_name missing'),
|
expect.stringContaining('extractedData.store_name missing'),
|
||||||
@@ -1190,7 +1201,7 @@ describe('AI Service (Server)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toHaveProperty('flyer_id', 100);
|
expect(result).toHaveProperty('flyer_id', 100);
|
||||||
expect(dbModule.adminRepo.logActivity).toHaveBeenCalledWith(
|
expect(mockAdminLogActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'flyer_processed',
|
action: 'flyer_processed',
|
||||||
userId: mockProfile.user.user_id,
|
userId: mockProfile.user.user_id,
|
||||||
@@ -1220,6 +1231,29 @@ describe('AI Service (Server)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log and re-throw the original error if the database transaction fails', async () => {
|
||||||
|
const body = { checksum: 'legacy-fail-checksum', extractedData: { store_name: 'Fail Store' } };
|
||||||
|
const dbError = new Error('DB transaction failed');
|
||||||
|
|
||||||
|
// Mock withTransaction to fail
|
||||||
|
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
aiServiceInstance.processLegacyFlyerUpload(
|
||||||
|
mockFile,
|
||||||
|
body,
|
||||||
|
mockProfile,
|
||||||
|
mockLoggerInstance,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(dbError);
|
||||||
|
|
||||||
|
// Verify the service-level error logging
|
||||||
|
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||||
|
{ err: dbError, checksum: 'legacy-fail-checksum' },
|
||||||
|
'Legacy flyer upload database transaction failed.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle body as a string', async () => {
|
it('should handle body as a string', async () => {
|
||||||
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
|
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
|
||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
@@ -1235,6 +1269,7 @@ describe('AI Service (Server)', () => {
|
|||||||
expect.objectContaining({ checksum: 'str-body' }),
|
expect.objectContaining({ checksum: 'str-body' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1244,56 +1279,4 @@ describe('AI Service (Server)', () => {
|
|||||||
expect(aiServiceSingleton).toBeInstanceOf(AIService);
|
expect(aiServiceSingleton).toBeInstanceOf(AIService);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_normalizeExtractedItems (private method)', () => {
|
|
||||||
it('should correctly normalize items with null or undefined price_in_cents', () => {
|
|
||||||
const rawItems: RawFlyerItem[] = [
|
|
||||||
{
|
|
||||||
item: 'Valid Item',
|
|
||||||
price_display: '$1.99',
|
|
||||||
price_in_cents: 199,
|
|
||||||
quantity: '1',
|
|
||||||
category_name: 'Category A',
|
|
||||||
master_item_id: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item: 'Item with Null Price',
|
|
||||||
price_display: null,
|
|
||||||
price_in_cents: null, // Test case for null
|
|
||||||
quantity: '1',
|
|
||||||
category_name: 'Category B',
|
|
||||||
master_item_id: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item: 'Item with Undefined Price',
|
|
||||||
price_display: '$2.99',
|
|
||||||
price_in_cents: undefined, // Test case for undefined
|
|
||||||
quantity: '1',
|
|
||||||
category_name: 'Category C',
|
|
||||||
master_item_id: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item: null, // Test null item name
|
|
||||||
price_display: undefined, // Test undefined display price
|
|
||||||
price_in_cents: 50,
|
|
||||||
quantity: null, // Test null quantity
|
|
||||||
category_name: undefined, // Test undefined category
|
|
||||||
master_item_id: null, // Test null master_item_id
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Access the private method for testing
|
|
||||||
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems);
|
|
||||||
|
|
||||||
expect(normalized).toHaveLength(4);
|
|
||||||
expect(normalized[0].price_in_cents).toBe(199);
|
|
||||||
expect(normalized[1].price_in_cents).toBe(null); // null should remain null
|
|
||||||
expect(normalized[2].price_in_cents).toBe(null); // undefined should become null
|
|
||||||
expect(normalized[3].item).toBe('Unknown Item');
|
|
||||||
expect(normalized[3].quantity).toBe('');
|
|
||||||
expect(normalized[3].category_name).toBe('Other/Miscellaneous');
|
|
||||||
expect(normalized[3].master_item_id).toBeUndefined(); // nullish coalescing to undefined
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import type {
|
|||||||
FlyerInsert,
|
FlyerInsert,
|
||||||
Flyer,
|
Flyer,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { FlyerProcessingError } from './processingErrors';
|
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
||||||
import * as db from './db/index.db';
|
import * as db from './db/index.db';
|
||||||
import { flyerQueue } from './queueService.server';
|
import { flyerQueue } from './queueService.server';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import { createFlyerAndItems } from './db/flyer.db';
|
import { createFlyerAndItems } from './db/flyer.db';
|
||||||
import { getBaseUrl } from '../utils/serverUtils';
|
import { getBaseUrl } from '../utils/serverUtils';
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
|
import { AdminRepository } from './db/admin.db';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
||||||
import {
|
import {
|
||||||
@@ -538,12 +539,8 @@ export class AIService {
|
|||||||
userProfileAddress?: string,
|
userProfileAddress?: string,
|
||||||
logger: Logger = this.logger,
|
logger: Logger = this.logger,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
store_name: string | null;
|
store_name: string | null; valid_from: string | null; valid_to: string | null; store_address: string | null; items: z.infer<typeof ExtractedFlyerItemSchema>[];
|
||||||
valid_from: string | null;
|
} & z.infer<typeof AiFlyerDataSchema>> {
|
||||||
valid_to: string | null;
|
|
||||||
store_address: string | null;
|
|
||||||
items: ExtractedFlyerItem[];
|
|
||||||
}> {
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[extractCoreDataFromFlyerImage] Entering method with ${imagePaths.length} image(s).`,
|
`[extractCoreDataFromFlyerImage] Entering method with ${imagePaths.length} image(s).`,
|
||||||
);
|
);
|
||||||
@@ -599,50 +596,22 @@ export class AIService {
|
|||||||
throw new Error('AI response did not contain a valid JSON object.');
|
throw new Error('AI response did not contain a valid JSON object.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize the items to create a clean data structure.
|
// The FlyerDataTransformer is now responsible for all normalization.
|
||||||
logger.debug('[extractCoreDataFromFlyerImage] Normalizing extracted items.');
|
// We return the raw items as parsed from the AI response.
|
||||||
const normalizedItems = Array.isArray(extractedData.items)
|
if (!Array.isArray(extractedData.items)) {
|
||||||
? this._normalizeExtractedItems(extractedData.items)
|
extractedData.items = [];
|
||||||
: [];
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[extractCoreDataFromFlyerImage] Successfully processed flyer data for store: ${extractedData.store_name}. Exiting method.`,
|
`[extractCoreDataFromFlyerImage] Successfully processed flyer data for store: ${extractedData.store_name}. Exiting method.`,
|
||||||
);
|
);
|
||||||
return { ...extractedData, items: normalizedItems };
|
return extractedData;
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
logger.error({ err: apiError }, '[extractCoreDataFromFlyerImage] The entire process failed.');
|
logger.error({ err: apiError }, '[extractCoreDataFromFlyerImage] The entire process failed.');
|
||||||
throw apiError;
|
throw apiError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the raw items returned by the AI, ensuring fields are in the correct format.
|
|
||||||
* @param items An array of raw flyer items from the AI.
|
|
||||||
* @returns A normalized array of flyer items.
|
|
||||||
*/
|
|
||||||
private _normalizeExtractedItems(items: RawFlyerItem[]): ExtractedFlyerItem[] {
|
|
||||||
return items.map((item: RawFlyerItem) => ({
|
|
||||||
...item,
|
|
||||||
// Ensure 'item' is always a string, defaulting to 'Unknown Item' if null/undefined.
|
|
||||||
item:
|
|
||||||
item.item === null || item.item === undefined || String(item.item).trim() === ''
|
|
||||||
? 'Unknown Item'
|
|
||||||
: String(item.item),
|
|
||||||
price_display:
|
|
||||||
item.price_display === null || item.price_display === undefined
|
|
||||||
? ''
|
|
||||||
: String(item.price_display),
|
|
||||||
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
|
|
||||||
category_name:
|
|
||||||
item.category_name === null || item.category_name === undefined
|
|
||||||
? 'Other/Miscellaneous'
|
|
||||||
: String(item.category_name),
|
|
||||||
// Ensure undefined is converted to null to match the Zod schema.
|
|
||||||
price_in_cents: item.price_in_cents ?? null,
|
|
||||||
master_item_id: item.master_item_id ?? undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SERVER-SIDE FUNCTION
|
* SERVER-SIDE FUNCTION
|
||||||
* Extracts a specific piece of text from a cropped area of an image.
|
* Extracts a specific piece of text from a cropped area of an image.
|
||||||
@@ -948,18 +917,28 @@ async enqueueFlyerProcessing(
|
|||||||
uploaded_by: userProfile?.user.user_id,
|
uploaded_by: userProfile?.user.user_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { flyer: newFlyer, items: newItems } = await createFlyerAndItems(flyerData, itemsForDb, logger);
|
return db.withTransaction(async (client) => {
|
||||||
|
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||||
|
|
||||||
logger.info(`Successfully processed legacy flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id}) with ${newItems.length} items.`);
|
logger.info(
|
||||||
|
`Successfully processed legacy flyer: ${flyer.file_name} (ID: ${flyer.flyer_id}) with ${items.length} items.`,
|
||||||
|
);
|
||||||
|
|
||||||
await db.adminRepo.logActivity({
|
const transactionalAdminRepo = new AdminRepository(client);
|
||||||
userId: userProfile?.user.user_id,
|
await transactionalAdminRepo.logActivity(
|
||||||
action: 'flyer_processed',
|
{
|
||||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
userId: userProfile?.user.user_id,
|
||||||
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
|
action: 'flyer_processed',
|
||||||
}, logger);
|
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||||
|
details: { flyerId: flyer.flyer_id, storeName: flyerData.store_name },
|
||||||
return newFlyer;
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
return flyer;
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error({ err: error, checksum }, 'Legacy flyer upload database transaction failed.');
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import type * as jsonwebtoken from 'jsonwebtoken';
|
import type * as jsonwebtoken from 'jsonwebtoken';
|
||||||
|
import { DatabaseError } from './processingErrors';
|
||||||
|
|
||||||
|
const { transactionalUserRepoMocks, transactionalAdminRepoMocks } = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
transactionalUserRepoMocks: {
|
||||||
|
updateUserPassword: vi.fn(),
|
||||||
|
deleteResetToken: vi.fn(),
|
||||||
|
createPasswordResetToken: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
},
|
||||||
|
transactionalAdminRepoMocks: {
|
||||||
|
logActivity: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./db/user.db', () => ({
|
||||||
|
UserRepository: vi.fn().mockImplementation(() => transactionalUserRepoMocks),
|
||||||
|
}));
|
||||||
|
vi.mock('./db/admin.db', () => ({
|
||||||
|
AdminRepository: vi.fn().mockImplementation(() => transactionalAdminRepoMocks),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let authService: typeof import('./authService').authService;
|
let authService: typeof import('./authService').authService;
|
||||||
@@ -12,6 +34,8 @@ describe('AuthService', () => {
|
|||||||
let logger: typeof import('./logger.server').logger;
|
let logger: typeof import('./logger.server').logger;
|
||||||
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
|
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
|
||||||
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
|
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
|
||||||
|
let RepositoryError: typeof import('./db/errors.db').RepositoryError;
|
||||||
|
let withTransaction: typeof import('./db/index.db').withTransaction;
|
||||||
|
|
||||||
const reqLog = {}; // Mock request logger object
|
const reqLog = {}; // Mock request logger object
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
@@ -41,6 +65,7 @@ describe('AuthService', () => {
|
|||||||
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
|
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
|
||||||
vi.mock('bcrypt');
|
vi.mock('bcrypt');
|
||||||
vi.mock('./db/index.db', () => ({
|
vi.mock('./db/index.db', () => ({
|
||||||
|
withTransaction: vi.fn(),
|
||||||
userRepo: {
|
userRepo: {
|
||||||
createUser: vi.fn(),
|
createUser: vi.fn(),
|
||||||
saveRefreshToken: vi.fn(),
|
saveRefreshToken: vi.fn(),
|
||||||
@@ -74,8 +99,15 @@ describe('AuthService', () => {
|
|||||||
userRepo = dbModule.userRepo;
|
userRepo = dbModule.userRepo;
|
||||||
adminRepo = dbModule.adminRepo;
|
adminRepo = dbModule.adminRepo;
|
||||||
logger = (await import('./logger.server')).logger;
|
logger = (await import('./logger.server')).logger;
|
||||||
|
withTransaction = (await import('./db/index.db')).withTransaction;
|
||||||
|
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
||||||
|
return callback({}); // Mock client
|
||||||
|
});
|
||||||
|
const { validatePasswordStrength } = await import('../utils/authUtils');
|
||||||
|
vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: true, feedback: '' });
|
||||||
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
||||||
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
||||||
|
RepositoryError = (await import('./db/errors.db')).RepositoryError;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -85,7 +117,7 @@ describe('AuthService', () => {
|
|||||||
describe('registerUser', () => {
|
describe('registerUser', () => {
|
||||||
it('should successfully register a new user', async () => {
|
it('should successfully register a new user', async () => {
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile);
|
||||||
|
|
||||||
const result = await authService.registerUser(
|
const result = await authService.registerUser(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -96,13 +128,14 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
||||||
expect(userRepo.createUser).toHaveBeenCalledWith(
|
expect(transactionalUserRepoMocks.createUser).toHaveBeenCalledWith(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
'hashed-password',
|
'hashed-password',
|
||||||
{ full_name: 'Test User', avatar_url: undefined },
|
{ full_name: 'Test User', avatar_url: undefined },
|
||||||
reqLog,
|
reqLog,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(adminRepo.logActivity).toHaveBeenCalledWith(
|
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'user_registered',
|
action: 'user_registered',
|
||||||
userId: 'user-123',
|
userId: 'user-123',
|
||||||
@@ -115,25 +148,25 @@ describe('AuthService', () => {
|
|||||||
it('should throw UniqueConstraintError if email already exists', async () => {
|
it('should throw UniqueConstraintError if email already exists', async () => {
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
const error = new UniqueConstraintError('Email exists');
|
const error = new UniqueConstraintError('Email exists');
|
||||||
vi.mocked(userRepo.createUser).mockRejectedValue(error);
|
vi.mocked(withTransaction).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
||||||
).rejects.toThrow(UniqueConstraintError);
|
).rejects.toThrow(UniqueConstraintError);
|
||||||
|
|
||||||
expect(logger.error).not.toHaveBeenCalled(); // Should not log expected unique constraint errors as system errors
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and throw other errors', async () => {
|
it('should log and re-throw generic errors on registration failure', async () => {
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
const error = new Error('Database failed');
|
const error = new Error('Database failed');
|
||||||
vi.mocked(userRepo.createUser).mockRejectedValue(error);
|
vi.mocked(withTransaction).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
||||||
).rejects.toThrow('Database failed');
|
).rejects.toThrow(error);
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalledWith({ error, email: 'test@example.com' }, `User registration failed.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +174,7 @@ describe('AuthService', () => {
|
|||||||
it('should register user and return tokens', async () => {
|
it('should register user and return tokens', async () => {
|
||||||
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
|
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile);
|
||||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||||
// We must mock `jwt.default.sign` to affect the code under test.
|
// We must mock `jwt.default.sign` to affect the code under test.
|
||||||
@@ -199,17 +232,13 @@ describe('AuthService', () => {
|
|||||||
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
|
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and throw error on failure', async () => {
|
it('should propagate the error from the repository on failure', async () => {
|
||||||
const error = new Error('DB Error');
|
const error = new Error('DB Error');
|
||||||
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
|
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
|
// The service method now directly propagates the error from the repo.
|
||||||
'DB Error',
|
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(error);
|
||||||
);
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ error }),
|
|
||||||
expect.stringContaining('Failed to save refresh token'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,11 +249,12 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const result = await authService.resetPassword('test@example.com', reqLog);
|
const result = await authService.resetPassword('test@example.com', reqLog);
|
||||||
|
|
||||||
expect(userRepo.createPasswordResetToken).toHaveBeenCalledWith(
|
expect(transactionalUserRepoMocks.createPasswordResetToken).toHaveBeenCalledWith(
|
||||||
'user-123',
|
'user-123',
|
||||||
'hashed-token',
|
'hashed-token',
|
||||||
expect.any(Date),
|
expect.any(Date),
|
||||||
reqLog,
|
reqLog,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
|
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -258,36 +288,50 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('updatePassword', () => {
|
describe('updatePassword', () => {
|
||||||
it('should update password if token is valid', async () => {
|
it('should update password if token is valid and wrap operations in a transaction', async () => {
|
||||||
const mockTokenRecord = {
|
const mockTokenRecord = {
|
||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
token_hash: 'hashed-token',
|
token_hash: 'hashed-token',
|
||||||
};
|
};
|
||||||
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
|
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
|
||||||
vi.mocked(bcrypt.compare).mockImplementation(async () => true); // Match found
|
vi.mocked(bcrypt.compare).mockImplementation(async () => true);
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
|
||||||
|
|
||||||
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
|
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
|
||||||
|
|
||||||
expect(userRepo.updateUserPassword).toHaveBeenCalledWith(
|
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(transactionalUserRepoMocks.updateUserPassword).toHaveBeenCalledWith(
|
||||||
'user-123',
|
'user-123',
|
||||||
'new-hashed-password',
|
'new-hashed-password',
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
expect(userRepo.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
|
expect(transactionalUserRepoMocks.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
|
||||||
expect(adminRepo.logActivity).toHaveBeenCalledWith(
|
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'password_reset' }),
|
expect.objectContaining({ action: 'password_reset' }),
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log and re-throw an error if the transaction fails', async () => {
|
||||||
|
const mockTokenRecord = { user_id: 'user-123', token_hash: 'hashed-token' };
|
||||||
|
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
|
||||||
|
vi.mocked(bcrypt.compare).mockImplementation(async () => true);
|
||||||
|
const dbError = new Error('Transaction failed');
|
||||||
|
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(authService.updatePassword('valid-token', 'newPassword', reqLog)).rejects.toThrow(dbError);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `An error occurred during password update.`);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return null if token is invalid or not found', async () => {
|
it('should return null if token is invalid or not found', async () => {
|
||||||
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);
|
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);
|
const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);
|
||||||
|
|
||||||
expect(userRepo.updateUserPassword).not.toHaveBeenCalled();
|
expect(transactionalUserRepoMocks.updateUserPassword).not.toHaveBeenCalled();
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -309,6 +353,37 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw a DatabaseError if finding the user fails with a generic error', async () => {
|
||||||
|
const dbError = new Error('DB connection failed');
|
||||||
|
vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
// Use a try-catch to assert on the error instance properties, which is more robust
|
||||||
|
// than `toBeInstanceOf` in some complex module mocking scenarios in Vitest.
|
||||||
|
try {
|
||||||
|
await authService.getUserByRefreshToken('any-token', reqLog);
|
||||||
|
expect.fail('Expected an error to be thrown');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.name).toBe('DatabaseError');
|
||||||
|
expect(error.message).toBe('DB connection failed');
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: dbError, refreshToken: 'any-token' },
|
||||||
|
'An unexpected error occurred while fetching user by refresh token.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw a RepositoryError if finding the user fails with a known error', async () => {
|
||||||
|
const repoError = new RepositoryError('Some repo error', 500);
|
||||||
|
vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(repoError);
|
||||||
|
|
||||||
|
await expect(authService.getUserByRefreshToken('any-token', reqLog)).rejects.toThrow(repoError);
|
||||||
|
// The original error is re-thrown, so the generic wrapper log should not be called.
|
||||||
|
expect(logger.error).not.toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
'An unexpected error occurred while fetching user by refresh token.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
@@ -317,12 +392,12 @@ describe('AuthService', () => {
|
|||||||
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
|
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and throw on error', async () => {
|
it('should propagate the error from the repository on failure', async () => {
|
||||||
const error = new Error('DB Error');
|
const error = new Error('DB Error');
|
||||||
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
|
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(authService.logout('token', reqLog)).rejects.toThrow('DB Error');
|
await expect(authService.logout('token', reqLog)).rejects.toThrow(error);
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,5 +420,13 @@ describe('AuthService', () => {
|
|||||||
const result = await authService.refreshAccessToken('invalid-token', reqLog);
|
const result = await authService.refreshAccessToken('invalid-token', reqLog);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should propagate errors from getUserByRefreshToken', async () => {
|
||||||
|
const dbError = new DatabaseError('Underlying DB call failed');
|
||||||
|
// We mock the service's own method since refreshAccessToken calls it directly.
|
||||||
|
vi.spyOn(authService, 'getUserByRefreshToken').mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(authService.refreshAccessToken('any-token', reqLog)).rejects.toThrow(dbError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { userRepo, adminRepo } from './db/index.db';
|
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
||||||
import { UniqueConstraintError } from './db/errors.db';
|
import { withTransaction, userRepo } from './db/index.db';
|
||||||
import { getPool } from './db/connection.db';
|
import { RepositoryError, ValidationError } from './db/errors.db';
|
||||||
import { logger } from './logger.server';
|
import { logger } from './logger.server';
|
||||||
import { sendPasswordResetEmail } from './emailService.server';
|
import { sendPasswordResetEmail } from './emailService.server';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
@@ -20,44 +20,48 @@ class AuthService {
|
|||||||
avatarUrl: string | undefined,
|
avatarUrl: string | undefined,
|
||||||
reqLog: any,
|
reqLog: any,
|
||||||
) {
|
) {
|
||||||
try {
|
const strength = validatePasswordStrength(password);
|
||||||
|
if (!strength.isValid) {
|
||||||
|
throw new ValidationError([], strength.feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap user creation and activity logging in a transaction for atomicity.
|
||||||
|
// The `createUser` method is now designed to be composed within other transactions.
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||||
|
const adminRepo = new (await import('./db/admin.db')).AdminRepository(client);
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
logger.info(`Hashing password for new user: ${email}`);
|
logger.info(`Hashing password for new user: ${email}`);
|
||||||
|
|
||||||
// The createUser method in UserRepository now handles its own transaction.
|
const newUser = await transactionalUserRepo.createUser(
|
||||||
const newUser = await userRepo.createUser(
|
|
||||||
email,
|
email,
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
{ full_name: fullName, avatar_url: avatarUrl },
|
{ full_name: fullName, avatar_url: avatarUrl },
|
||||||
reqLog,
|
reqLog,
|
||||||
|
client, // Pass the transactional client
|
||||||
);
|
);
|
||||||
|
|
||||||
const userEmail = newUser.user.email;
|
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
||||||
const userId = newUser.user.user_id;
|
|
||||||
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
|
|
||||||
|
|
||||||
// Use the new standardized logging function
|
|
||||||
await adminRepo.logActivity(
|
await adminRepo.logActivity(
|
||||||
{
|
{ userId: newUser.user.user_id, action: 'user_registered', displayText: `${email} has registered.`, icon: 'user-plus' },
|
||||||
userId: newUser.user.user_id,
|
|
||||||
action: 'user_registered',
|
|
||||||
displayText: `${userEmail} has registered.`,
|
|
||||||
icon: 'user-plus',
|
|
||||||
},
|
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
} catch (error: unknown) {
|
}).catch((error: unknown) => {
|
||||||
if (error instanceof UniqueConstraintError) {
|
// Re-throw known repository errors (like UniqueConstraintError) to allow for specific handling upstream.
|
||||||
// If the email is a duplicate, return a 409 Conflict status.
|
if (error instanceof RepositoryError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
logger.error({ error }, `User registration route failed for email: ${email}.`);
|
// For unknown errors, log them and wrap them in a generic DatabaseError
|
||||||
// Pass the error to the centralized handler
|
// to standardize the error contract of the service layer.
|
||||||
throw error;
|
const message = error instanceof Error ? error.message : 'An unknown error occurred during registration.';
|
||||||
}
|
logger.error({ error, email }, `User registration failed with an unexpected error.`);
|
||||||
|
throw new DatabaseError(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerAndLoginUser(
|
async registerAndLoginUser(
|
||||||
@@ -91,15 +95,9 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
|
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
|
||||||
try {
|
// The repository method `saveRefreshToken` already includes robust error handling
|
||||||
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
|
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
||||||
} catch (tokenErr) {
|
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
|
||||||
logger.error(
|
|
||||||
{ error: tokenErr },
|
|
||||||
`Failed to save refresh token during login for user: ${userId}`,
|
|
||||||
);
|
|
||||||
throw tokenErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
|
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
|
||||||
@@ -124,7 +122,11 @@ class AuthService {
|
|||||||
const tokenHash = await bcrypt.hash(token, saltRounds);
|
const tokenHash = await bcrypt.hash(token, saltRounds);
|
||||||
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
|
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
|
||||||
|
|
||||||
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog);
|
// Wrap the token creation in a transaction to ensure atomicity of the DELETE and INSERT operations.
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||||
|
await transactionalUserRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog, client);
|
||||||
|
});
|
||||||
|
|
||||||
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
||||||
|
|
||||||
@@ -139,13 +141,29 @@ class AuthService {
|
|||||||
|
|
||||||
return token;
|
return token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
|
// Re-throw known repository errors to allow for specific handling upstream.
|
||||||
throw error;
|
if (error instanceof RepositoryError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||||
|
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error, email }, `An unexpected error occurred during password reset for email: ${email}`);
|
||||||
|
throw new DatabaseError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePassword(token: string, newPassword: string, reqLog: any) {
|
async updatePassword(token: string, newPassword: string, reqLog: any) {
|
||||||
try {
|
const strength = validatePasswordStrength(newPassword);
|
||||||
|
if (!strength.isValid) {
|
||||||
|
throw new ValidationError([], strength.feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap all database operations in a transaction to ensure atomicity.
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||||
|
const adminRepo = new (await import('./db/admin.db')).AdminRepository(client);
|
||||||
|
|
||||||
|
// This read can happen outside the transaction if we use the non-transactional repo.
|
||||||
const validTokens = await userRepo.getValidResetTokens(reqLog);
|
const validTokens = await userRepo.getValidResetTokens(reqLog);
|
||||||
let tokenRecord;
|
let tokenRecord;
|
||||||
for (const record of validTokens) {
|
for (const record of validTokens) {
|
||||||
@@ -157,32 +175,31 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return null;
|
return null; // Token is invalid or expired, not an error.
|
||||||
}
|
}
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
|
// These three writes are now atomic.
|
||||||
await userRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
|
await transactionalUserRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
|
||||||
|
await transactionalUserRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
|
||||||
// Log this security event after a successful password reset.
|
|
||||||
await adminRepo.logActivity(
|
await adminRepo.logActivity(
|
||||||
{
|
{ userId: tokenRecord.user_id, action: 'password_reset', displayText: `User ID ${tokenRecord.user_id} has reset their password.`, icon: 'key' },
|
||||||
userId: tokenRecord.user_id,
|
|
||||||
action: 'password_reset',
|
|
||||||
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
|
|
||||||
icon: 'key',
|
|
||||||
details: { source_ip: null },
|
|
||||||
},
|
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
}).catch((error) => {
|
||||||
logger.error({ error }, `An error occurred during password reset.`);
|
// Re-throw known repository errors to allow for specific handling upstream.
|
||||||
throw error;
|
if (error instanceof RepositoryError) {
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||||
|
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error }, `An unexpected error occurred during password update.`);
|
||||||
|
throw new DatabaseError(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
|
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
|
||||||
@@ -194,18 +211,22 @@ class AuthService {
|
|||||||
const userProfile = await userRepo.findUserProfileById(basicUser.user_id, reqLog);
|
const userProfile = await userRepo.findUserProfileById(basicUser.user_id, reqLog);
|
||||||
return userProfile;
|
return userProfile;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'An error occurred during /refresh-token.');
|
// Re-throw known repository errors to allow for specific handling upstream.
|
||||||
throw error;
|
if (error instanceof RepositoryError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error, refreshToken }, 'An unexpected error occurred while fetching user by refresh token.');
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(refreshToken: string, reqLog: any) {
|
async logout(refreshToken: string, reqLog: any) {
|
||||||
try {
|
// The repository method `deleteRefreshToken` now includes robust error handling
|
||||||
await userRepo.deleteRefreshToken(refreshToken, reqLog);
|
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
||||||
} catch (err: any) {
|
// The original implementation also swallowed errors, which is now fixed.
|
||||||
logger.error({ error: err }, 'Failed to delete refresh token from DB during logout.');
|
await userRepo.deleteRefreshToken(refreshToken, reqLog);
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
|
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
RepositoryError,
|
||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
@@ -18,17 +18,17 @@ import {
|
|||||||
vi.mock('./logger.server');
|
vi.mock('./logger.server');
|
||||||
|
|
||||||
describe('Custom Database and Application Errors', () => {
|
describe('Custom Database and Application Errors', () => {
|
||||||
describe('DatabaseError', () => {
|
describe('RepositoryError', () => {
|
||||||
it('should create a generic database error with a message and status', () => {
|
it('should create a generic database error with a message and status', () => {
|
||||||
const message = 'Generic DB Error';
|
const message = 'Generic DB Error';
|
||||||
const status = 500;
|
const status = 500;
|
||||||
const error = new DatabaseError(message, status);
|
const error = new RepositoryError(message, status);
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe(message);
|
expect(error.message).toBe(message);
|
||||||
expect(error.status).toBe(status);
|
expect(error.status).toBe(status);
|
||||||
expect(error.name).toBe('DatabaseError');
|
expect(error.name).toBe('RepositoryError');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new UniqueConstraintError();
|
const error = new UniqueConstraintError();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(UniqueConstraintError);
|
expect(error).toBeInstanceOf(UniqueConstraintError);
|
||||||
expect(error.message).toBe('The record already exists.');
|
expect(error.message).toBe('The record already exists.');
|
||||||
expect(error.status).toBe(409);
|
expect(error.status).toBe(409);
|
||||||
@@ -56,7 +56,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new ForeignKeyConstraintError();
|
const error = new ForeignKeyConstraintError();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(ForeignKeyConstraintError);
|
expect(error).toBeInstanceOf(ForeignKeyConstraintError);
|
||||||
expect(error.message).toBe('The referenced record does not exist.');
|
expect(error.message).toBe('The referenced record does not exist.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
@@ -75,7 +75,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new NotFoundError();
|
const error = new NotFoundError();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(NotFoundError);
|
expect(error).toBeInstanceOf(NotFoundError);
|
||||||
expect(error.message).toBe('The requested resource was not found.');
|
expect(error.message).toBe('The requested resource was not found.');
|
||||||
expect(error.status).toBe(404);
|
expect(error.status).toBe(404);
|
||||||
@@ -95,7 +95,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new ValidationError(validationIssues);
|
const error = new ValidationError(validationIssues);
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(ValidationError);
|
expect(error).toBeInstanceOf(ValidationError);
|
||||||
expect(error.message).toBe('The request data is invalid.');
|
expect(error.message).toBe('The request data is invalid.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
@@ -126,7 +126,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('NotNullConstraintError', () => {
|
describe('NotNullConstraintError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new NotNullConstraintError();
|
const error = new NotNullConstraintError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A required field was left null.');
|
expect(error.message).toBe('A required field was left null.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('NotNullConstraintError');
|
expect(error.name).toBe('NotNullConstraintError');
|
||||||
@@ -142,7 +142,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('CheckConstraintError', () => {
|
describe('CheckConstraintError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new CheckConstraintError();
|
const error = new CheckConstraintError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A check constraint was violated.');
|
expect(error.message).toBe('A check constraint was violated.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('CheckConstraintError');
|
expect(error.name).toBe('CheckConstraintError');
|
||||||
@@ -158,7 +158,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('InvalidTextRepresentationError', () => {
|
describe('InvalidTextRepresentationError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new InvalidTextRepresentationError();
|
const error = new InvalidTextRepresentationError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A value has an invalid format for its data type.');
|
expect(error.message).toBe('A value has an invalid format for its data type.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('InvalidTextRepresentationError');
|
expect(error.name).toBe('InvalidTextRepresentationError');
|
||||||
@@ -174,7 +174,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('NumericValueOutOfRangeError', () => {
|
describe('NumericValueOutOfRangeError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new NumericValueOutOfRangeError();
|
const error = new NumericValueOutOfRangeError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A numeric value is out of the allowed range.');
|
expect(error.message).toBe('A numeric value is out of the allowed range.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('NumericValueOutOfRangeError');
|
expect(error.name).toBe('NumericValueOutOfRangeError');
|
||||||
@@ -196,7 +196,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-throw existing DatabaseError instances without logging', () => {
|
it('should re-throw existing RepositoryError instances without logging', () => {
|
||||||
const notFound = new NotFoundError('Test not found');
|
const notFound = new NotFoundError('Test not found');
|
||||||
expect(() => handleDbError(notFound, mockLogger, 'msg', {})).toThrow(notFound);
|
expect(() => handleDbError(notFound, mockLogger, 'msg', {})).toThrow(notFound);
|
||||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// src/services/db/errors.db.ts
|
// src/services/db/errors.db.ts
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
|
import { DatabaseError as ProcessingDatabaseError } from '../processingErrors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for custom database errors to ensure they have a status property.
|
* Base class for custom repository-level errors to ensure they have a status property.
|
||||||
*/
|
*/
|
||||||
export class DatabaseError extends Error {
|
export class RepositoryError extends Error {
|
||||||
public status: number;
|
public status: number;
|
||||||
|
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number) {
|
||||||
@@ -20,7 +21,7 @@ export class DatabaseError extends Error {
|
|||||||
* Thrown when a unique constraint is violated (e.g., trying to register an existing email).
|
* Thrown when a unique constraint is violated (e.g., trying to register an existing email).
|
||||||
* Corresponds to PostgreSQL error code '23505'.
|
* Corresponds to PostgreSQL error code '23505'.
|
||||||
*/
|
*/
|
||||||
export class UniqueConstraintError extends DatabaseError {
|
export class UniqueConstraintError extends RepositoryError {
|
||||||
constructor(message = 'The record already exists.') {
|
constructor(message = 'The record already exists.') {
|
||||||
super(message, 409); // 409 Conflict
|
super(message, 409); // 409 Conflict
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ export class UniqueConstraintError extends DatabaseError {
|
|||||||
* Thrown when a foreign key constraint is violated (e.g., trying to reference a non-existent record).
|
* Thrown when a foreign key constraint is violated (e.g., trying to reference a non-existent record).
|
||||||
* Corresponds to PostgreSQL error code '23503'.
|
* Corresponds to PostgreSQL error code '23503'.
|
||||||
*/
|
*/
|
||||||
export class ForeignKeyConstraintError extends DatabaseError {
|
export class ForeignKeyConstraintError extends RepositoryError {
|
||||||
constructor(message = 'The referenced record does not exist.') {
|
constructor(message = 'The referenced record does not exist.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ export class ForeignKeyConstraintError extends DatabaseError {
|
|||||||
* Thrown when a 'not null' constraint is violated.
|
* Thrown when a 'not null' constraint is violated.
|
||||||
* Corresponds to PostgreSQL error code '23502'.
|
* Corresponds to PostgreSQL error code '23502'.
|
||||||
*/
|
*/
|
||||||
export class NotNullConstraintError extends DatabaseError {
|
export class NotNullConstraintError extends RepositoryError {
|
||||||
constructor(message = 'A required field was left null.') {
|
constructor(message = 'A required field was left null.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,7 @@ export class NotNullConstraintError extends DatabaseError {
|
|||||||
* Thrown when a 'check' constraint is violated.
|
* Thrown when a 'check' constraint is violated.
|
||||||
* Corresponds to PostgreSQL error code '23514'.
|
* Corresponds to PostgreSQL error code '23514'.
|
||||||
*/
|
*/
|
||||||
export class CheckConstraintError extends DatabaseError {
|
export class CheckConstraintError extends RepositoryError {
|
||||||
constructor(message = 'A check constraint was violated.') {
|
constructor(message = 'A check constraint was violated.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ export class CheckConstraintError extends DatabaseError {
|
|||||||
* Thrown when a value has an invalid text representation for its data type (e.g., 'abc' for an integer).
|
* Thrown when a value has an invalid text representation for its data type (e.g., 'abc' for an integer).
|
||||||
* Corresponds to PostgreSQL error code '22P02'.
|
* Corresponds to PostgreSQL error code '22P02'.
|
||||||
*/
|
*/
|
||||||
export class InvalidTextRepresentationError extends DatabaseError {
|
export class InvalidTextRepresentationError extends RepositoryError {
|
||||||
constructor(message = 'A value has an invalid format for its data type.') {
|
constructor(message = 'A value has an invalid format for its data type.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ export class InvalidTextRepresentationError extends DatabaseError {
|
|||||||
* Thrown when a numeric value is out of range for its data type (e.g., too large for an integer).
|
* Thrown when a numeric value is out of range for its data type (e.g., too large for an integer).
|
||||||
* Corresponds to PostgreSQL error code '22003'.
|
* Corresponds to PostgreSQL error code '22003'.
|
||||||
*/
|
*/
|
||||||
export class NumericValueOutOfRangeError extends DatabaseError {
|
export class NumericValueOutOfRangeError extends RepositoryError {
|
||||||
constructor(message = 'A numeric value is out of the allowed range.') {
|
constructor(message = 'A numeric value is out of the allowed range.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ export class NumericValueOutOfRangeError extends DatabaseError {
|
|||||||
/**
|
/**
|
||||||
* Thrown when a specific record is not found in the database.
|
* Thrown when a specific record is not found in the database.
|
||||||
*/
|
*/
|
||||||
export class NotFoundError extends DatabaseError {
|
export class NotFoundError extends RepositoryError {
|
||||||
constructor(message = 'The requested resource was not found.') {
|
constructor(message = 'The requested resource was not found.') {
|
||||||
super(message, 404); // 404 Not Found
|
super(message, 404); // 404 Not Found
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ export interface ValidationIssue {
|
|||||||
/**
|
/**
|
||||||
* Thrown when request validation fails (e.g., missing body fields or invalid params).
|
* Thrown when request validation fails (e.g., missing body fields or invalid params).
|
||||||
*/
|
*/
|
||||||
export class ValidationError extends DatabaseError {
|
export class ValidationError extends RepositoryError {
|
||||||
public validationErrors: ValidationIssue[];
|
public validationErrors: ValidationIssue[];
|
||||||
|
|
||||||
constructor(errors: ValidationIssue[], message = 'The request data is invalid.') {
|
constructor(errors: ValidationIssue[], message = 'The request data is invalid.') {
|
||||||
@@ -126,6 +127,15 @@ export interface HandleDbErrorOptions {
|
|||||||
defaultMessage?: string;
|
defaultMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type guard to check if an error object is a PostgreSQL error with a code.
|
||||||
|
*/
|
||||||
|
function isPostgresError(
|
||||||
|
error: unknown,
|
||||||
|
): error is { code: string; constraint?: string; detail?: string } {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized error handler for database repositories.
|
* Centralized error handler for database repositories.
|
||||||
* Logs the error and throws appropriate custom errors based on PostgreSQL error codes.
|
* Logs the error and throws appropriate custom errors based on PostgreSQL error codes.
|
||||||
@@ -138,26 +148,42 @@ export function handleDbError(
|
|||||||
options: HandleDbErrorOptions = {},
|
options: HandleDbErrorOptions = {},
|
||||||
): never {
|
): never {
|
||||||
// If it's already a known domain error (like NotFoundError thrown manually), rethrow it.
|
// If it's already a known domain error (like NotFoundError thrown manually), rethrow it.
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof RepositoryError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the raw error
|
if (isPostgresError(error)) {
|
||||||
logger.error({ err: error, ...logContext }, logMessage);
|
const { code, constraint, detail } = error;
|
||||||
|
const enhancedLogContext = { err: error, code, constraint, detail, ...logContext };
|
||||||
|
|
||||||
if (error instanceof Error && 'code' in error) {
|
// Log the detailed error first
|
||||||
const code = (error as any).code;
|
logger.error(enhancedLogContext, logMessage);
|
||||||
|
|
||||||
if (code === '23505') throw new UniqueConstraintError(options.uniqueMessage);
|
// Now, throw the appropriate custom error
|
||||||
if (code === '23503') throw new ForeignKeyConstraintError(options.fkMessage);
|
switch (code) {
|
||||||
if (code === '23502') throw new NotNullConstraintError(options.notNullMessage);
|
case '23505': // unique_violation
|
||||||
if (code === '23514') throw new CheckConstraintError(options.checkMessage);
|
throw new UniqueConstraintError(options.uniqueMessage);
|
||||||
if (code === '22P02') throw new InvalidTextRepresentationError(options.invalidTextMessage);
|
case '23503': // foreign_key_violation
|
||||||
if (code === '22003') throw new NumericValueOutOfRangeError(options.numericOutOfRangeMessage);
|
throw new ForeignKeyConstraintError(options.fkMessage);
|
||||||
|
case '23502': // not_null_violation
|
||||||
|
throw new NotNullConstraintError(options.notNullMessage);
|
||||||
|
case '23514': // check_violation
|
||||||
|
throw new CheckConstraintError(options.checkMessage);
|
||||||
|
case '22P02': // invalid_text_representation
|
||||||
|
throw new InvalidTextRepresentationError(options.invalidTextMessage);
|
||||||
|
case '22003': // numeric_value_out_of_range
|
||||||
|
throw new NumericValueOutOfRangeError(options.numericOutOfRangeMessage);
|
||||||
|
default:
|
||||||
|
// If it's a PG error but not one we handle specifically, fall through to the generic error.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Log the error if it wasn't a recognized Postgres error
|
||||||
|
logger.error({ err: error, ...logContext }, logMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback generic error
|
// Fallback generic error
|
||||||
throw new Error(
|
// Use the consistent DatabaseError from the processing errors module for the fallback.
|
||||||
options.defaultMessage || `Failed to perform operation on ${options.entityName || 'database'}.`,
|
const errorMessage = options.defaultMessage || `Failed to perform operation on ${options.entityName || 'database'}.`;
|
||||||
);
|
throw new ProcessingDatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
CheckConstraintError,
|
CheckConstraintError,
|
||||||
} from './errors.db';
|
} from './errors.db';
|
||||||
|
import { DatabaseError } from '../processingErrors';
|
||||||
import type {
|
import type {
|
||||||
FlyerInsert,
|
FlyerInsert,
|
||||||
FlyerItemInsert,
|
FlyerItemInsert,
|
||||||
@@ -350,8 +351,8 @@ describe('Flyer DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createFlyerAndItems', () => {
|
describe('createFlyerAndItems', () => {
|
||||||
it('should use withTransaction to create a flyer and items', async () => {
|
it('should execute find/create store, insert flyer, and insert items using the provided client', async () => {
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = { // This was a duplicate, fixed.
|
||||||
file_name: 'transact.jpg',
|
file_name: 'transact.jpg',
|
||||||
store_name: 'Transaction Store',
|
store_name: 'Transaction Store',
|
||||||
} as FlyerInsert;
|
} as FlyerInsert;
|
||||||
@@ -374,41 +375,31 @@ describe('Flyer DB Service', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock the withTransaction to execute the callback with a mock client
|
// Mock the sequence of 4 calls on the client
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
const mockClient = { query: vi.fn() };
|
||||||
const mockClient = { query: vi.fn() };
|
mockClient.query
|
||||||
// Mock the sequence of 4 calls within the transaction
|
// 1. findOrCreateStore: INSERT ... ON CONFLICT
|
||||||
mockClient.query
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
||||||
// 1. findOrCreateStore: INSERT ... ON CONFLICT
|
// 2. findOrCreateStore: SELECT store_id
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
||||||
// 2. findOrCreateStore: SELECT store_id
|
// 3. insertFlyer
|
||||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
||||||
// 3. insertFlyer
|
// 4. insertFlyerItems
|
||||||
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
.mockResolvedValueOnce({ rows: mockItems });
|
||||||
// 4. insertFlyerItems
|
|
||||||
.mockResolvedValueOnce({ rows: mockItems });
|
|
||||||
return callback(mockClient as unknown as PoolClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await createFlyerAndItems(flyerData, itemsData, mockLogger);
|
const result = await createFlyerAndItems(
|
||||||
|
flyerData,
|
||||||
|
itemsData,
|
||||||
|
mockLogger,
|
||||||
|
mockClient as unknown as PoolClient,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
flyer: mockFlyer,
|
flyer: mockFlyer,
|
||||||
items: mockItems,
|
items: mockItems,
|
||||||
});
|
});
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Verify the individual functions were called with the client
|
// Verify the individual functions were called with the client
|
||||||
const callback = (vi.mocked(withTransaction) as Mock).mock.calls[0][0];
|
|
||||||
const mockClient = { query: vi.fn() };
|
|
||||||
// Set up the same mock sequence for verification
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore 1
|
|
||||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore 2
|
|
||||||
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
|
|
||||||
.mockResolvedValueOnce({ rows: mockItems });
|
|
||||||
await callback(mockClient as unknown as PoolClient);
|
|
||||||
|
|
||||||
// findOrCreateStore assertions
|
// findOrCreateStore assertions
|
||||||
expect(mockClient.query).toHaveBeenCalledWith(
|
expect(mockClient.query).toHaveBeenCalledWith(
|
||||||
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||||
@@ -430,28 +421,27 @@ describe('Flyer DB Service', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and re-throw an error if the transaction fails', async () => {
|
it('should propagate an error if any step fails', async () => {
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = {
|
||||||
file_name: 'fail.jpg',
|
file_name: 'fail.jpg',
|
||||||
store_name: 'Fail Store',
|
store_name: 'Fail Store',
|
||||||
} as FlyerInsert;
|
} as FlyerInsert;
|
||||||
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
|
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
|
||||||
const transactionError = new Error('Underlying transaction failed');
|
const dbError = new Error('Underlying DB call failed');
|
||||||
|
|
||||||
// Mock withTransaction to reject directly
|
// Mock the client to fail on the insertFlyer step
|
||||||
vi.mocked(withTransaction).mockRejectedValue(transactionError);
|
const mockClient = { query: vi.fn() };
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
||||||
|
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
||||||
|
.mockRejectedValueOnce(dbError); // insertFlyer fails
|
||||||
|
|
||||||
// Expect the createFlyerAndItems function to reject with the same error
|
// The calling service's withTransaction would catch this.
|
||||||
await expect(createFlyerAndItems(flyerData, itemsData, mockLogger)).rejects.toThrow(
|
// Here, we just expect it to be thrown.
|
||||||
transactionError,
|
await expect(
|
||||||
);
|
createFlyerAndItems(flyerData, itemsData, mockLogger, mockClient as unknown as PoolClient),
|
||||||
|
// The error is wrapped by handleDbError, so we check for the wrapped error.
|
||||||
// Verify that the error was logged before being re-thrown
|
).rejects.toThrow(new DatabaseError('Failed to insert flyer into database.'));
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
||||||
{ err: transactionError },
|
|
||||||
'Database transaction error in createFlyerAndItems',
|
|
||||||
);
|
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -379,27 +379,23 @@ export async function createFlyerAndItems(
|
|||||||
flyerData: FlyerInsert,
|
flyerData: FlyerInsert,
|
||||||
itemsForDb: FlyerItemInsert[],
|
itemsForDb: FlyerItemInsert[],
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
client: PoolClient,
|
||||||
) {
|
) {
|
||||||
try {
|
// The calling service is now responsible for managing the transaction.
|
||||||
return await withTransaction(async (client) => {
|
// This function assumes it is being run within a transaction via the provided client.
|
||||||
const flyerRepo = new FlyerRepository(client);
|
const flyerRepo = new FlyerRepository(client);
|
||||||
|
|
||||||
// 1. Find or create the store to get the store_id
|
// 1. Find or create the store to get the store_id
|
||||||
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);
|
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);
|
||||||
|
|
||||||
// 2. Prepare the data for the flyer table, replacing store_name with store_id
|
// 2. Prepare the data for the flyer table, replacing store_name with store_id
|
||||||
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
|
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
|
||||||
|
|
||||||
// 3. Insert the flyer record
|
// 3. Insert the flyer record
|
||||||
const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);
|
const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);
|
||||||
|
|
||||||
// 4. Insert the associated flyer items
|
// 4. Insert the associated flyer items
|
||||||
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);
|
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);
|
||||||
|
|
||||||
return { flyer: newFlyer, items: newItems };
|
return { flyer: newFlyer, items: newItems };
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, 'Database transaction error in createFlyerAndItems');
|
|
||||||
throw error; // Re-throw the error to be handled by the calling service.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
|||||||
import { createMockUserProfile, createMockUser } from '../../tests/utils/mockFactories';
|
import { createMockUserProfile, createMockUser } from '../../tests/utils/mockFactories';
|
||||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||||
import type { Profile, ActivityLogItem, SearchQuery, UserProfile, User } from '../../types';
|
import type { Profile, ActivityLogItem, SearchQuery, UserProfile, User } from '../../types';
|
||||||
|
import { ShoppingRepository } from './shopping.db';
|
||||||
|
import { PersonalizationRepository } from './personalization.db';
|
||||||
|
|
||||||
// Mock other db services that are used by functions in user.db.ts
|
// Mock other db services that are used by functions in user.db.ts
|
||||||
// Update mocks to put methods on prototype so spyOn works in exportUserData tests
|
// Update mocks to put methods on prototype so spyOn works in exportUserData tests
|
||||||
@@ -115,14 +117,14 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createUser', () => {
|
describe('createUser', () => {
|
||||||
it('should execute a transaction to create a user and profile', async () => {
|
it('should create a user and profile successfully', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
user_id: 'new-user-id',
|
user_id: 'new-user-id',
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
// This is the flat structure returned by the DB query inside createUser
|
|
||||||
const mockDbProfile = {
|
const mockDbProfile = {
|
||||||
user_id: 'new-user-id',
|
user_id: 'new-user-id',
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
@@ -136,7 +138,7 @@ describe('User DB Service', () => {
|
|||||||
user_created_at: new Date().toISOString(),
|
user_created_at: new Date().toISOString(),
|
||||||
user_updated_at: new Date().toISOString(),
|
user_updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
// This is the nested structure the function is expected to return
|
|
||||||
const expectedProfile: UserProfile = {
|
const expectedProfile: UserProfile = {
|
||||||
user: {
|
user: {
|
||||||
user_id: mockDbProfile.user_id,
|
user_id: mockDbProfile.user_id,
|
||||||
@@ -153,14 +155,11 @@ describe('User DB Service', () => {
|
|||||||
updated_at: mockDbProfile.updated_at,
|
updated_at: mockDbProfile.updated_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
// Mock the sequence of queries on the main pool instance
|
||||||
const mockClient = { query: vi.fn(), release: vi.fn() };
|
(mockPoolInstance.query as Mock)
|
||||||
(mockClient.query as Mock)
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
||||||
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
|
||||||
return callback(mockClient as unknown as PoolClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await userRepo.createUser(
|
const result = await userRepo.createUser(
|
||||||
'new@example.com',
|
'new@example.com',
|
||||||
@@ -169,52 +168,38 @@ describe('User DB Service', () => {
|
|||||||
mockLogger,
|
mockLogger,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use objectContaining because the real implementation might have other DB-generated fields.
|
|
||||||
// We can't do a deep equality check on the user object because the mock factory will generate different timestamps.
|
|
||||||
expect(result.user.user_id).toEqual(expectedProfile.user.user_id);
|
expect(result.user.user_id).toEqual(expectedProfile.user.user_id);
|
||||||
expect(result.full_name).toEqual(expectedProfile.full_name);
|
expect(result.full_name).toEqual(expectedProfile.full_name);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
expect(result).toEqual(expect.objectContaining(expectedProfile));
|
expect(result).toEqual(expect.objectContaining(expectedProfile));
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should rollback the transaction if creating the user fails', async () => {
|
it('should throw an error if creating the user fails', async () => {
|
||||||
const dbError = new Error('User insert failed');
|
const dbError = new Error('User insert failed');
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
const mockClient = { query: vi.fn() };
|
|
||||||
mockClient.query.mockRejectedValueOnce(dbError); // set_config or INSERT fails
|
|
||||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(dbError);
|
|
||||||
throw dbError;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger),
|
userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger),
|
||||||
).rejects.toThrow('Failed to create user in database.');
|
).rejects.toThrow('Failed to create user in database.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, email: 'fail@example.com' },
|
{ err: dbError, email: 'fail@example.com' },
|
||||||
'Error during createUser transaction',
|
'Error during createUser',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should rollback the transaction if fetching the final profile fails', async () => {
|
it('should throw an error if fetching the final profile fails', async () => {
|
||||||
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
|
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
|
||||||
const dbError = new Error('Profile fetch failed');
|
const dbError = new Error('Profile fetch failed');
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
(mockPoolInstance.query as Mock)
|
||||||
const mockClient = { query: vi.fn() };
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
mockClient.query
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
.mockRejectedValueOnce(dbError); // SELECT profile fails
|
||||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
|
||||||
.mockRejectedValueOnce(dbError); // SELECT profile fails
|
|
||||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(dbError);
|
|
||||||
throw dbError;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow(
|
await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow(
|
||||||
'Failed to create user in database.',
|
'Failed to create user in database.',
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, email: 'fail@example.com' },
|
{ err: dbError, email: 'fail@example.com' },
|
||||||
'Error during createUser transaction',
|
'Error during createUser',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,7 +207,7 @@ describe('User DB Service', () => {
|
|||||||
const dbError = new Error('duplicate key value violates unique constraint');
|
const dbError = new Error('duplicate key value violates unique constraint');
|
||||||
(dbError as Error & { code: string }).code = '23505';
|
(dbError as Error & { code: string }).code = '23505';
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userRepo.createUser('exists@example.com', 'pass', {}, mockLogger);
|
await userRepo.createUser('exists@example.com', 'pass', {}, mockLogger);
|
||||||
@@ -232,36 +217,26 @@ describe('User DB Service', () => {
|
|||||||
// After confirming the error type, we can safely access its properties.
|
// After confirming the error type, we can safely access its properties.
|
||||||
// This satisfies TypeScript's type checker for the 'unknown' type.
|
// This satisfies TypeScript's type checker for the 'unknown' type.
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
expect(error.message).toBe('A user with this email address already exists.');
|
expect(error.message).toBe('A user with this email address already exists.'); // This message comes from the options in handleDbError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(`Attempted to create a user with an existing email: exists@example.com`);
|
expect(mockLogger.warn).toHaveBeenCalledWith(`Attempted to create a user with an existing email: exists@example.com`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if profile is not found after user creation', async () => {
|
it('should throw an error if profile is not found after user creation', async () => {
|
||||||
const mockUser = { user_id: 'new-user-id', email: 'no-profile@example.com' };
|
const mockUser = { user_id: 'new-user-id', email: 'no-profile@example.com' };
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
(mockPoolInstance.query as Mock)
|
||||||
const mockClient = { query: vi.fn() };
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
mockClient.query
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds
|
||||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
||||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds
|
|
||||||
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
|
||||||
// The callback will throw, which is caught and re-thrown by withTransaction
|
|
||||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
|
|
||||||
'Failed to create or retrieve user profile after registration.',
|
|
||||||
);
|
|
||||||
throw new Error('Internal failure'); // Simulate re-throw from withTransaction
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger),
|
userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger),
|
||||||
).rejects.toThrow('Failed to create user in database.');
|
).rejects.toThrow('Failed to create user in database.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: expect.any(Error), email: 'no-profile@example.com' },
|
{ err: expect.any(Error), email: 'no-profile@example.com' },
|
||||||
'Error during createUser transaction',
|
'Error during createUser',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -669,23 +644,12 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteRefreshToken', () => {
|
describe('deleteRefreshToken', () => {
|
||||||
it('should execute an UPDATE query to set the refresh token to NULL', async () => {
|
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
|
||||||
await userRepo.deleteRefreshToken('a-token', mockLogger);
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
||||||
'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1',
|
|
||||||
['a-token'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error but not throw if the database query fails', async () => {
|
it('should log an error but not throw if the database query fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
|
||||||
// The function is designed to swallow errors, so we expect it to resolve.
|
|
||||||
await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined();
|
await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined();
|
||||||
|
|
||||||
// We can still check that the query was attempted.
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalled();
|
expect(mockPoolInstance.query).toHaveBeenCalled();
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError },
|
{ err: dbError },
|
||||||
@@ -696,14 +660,14 @@ describe('User DB Service', () => {
|
|||||||
|
|
||||||
describe('createPasswordResetToken', () => {
|
describe('createPasswordResetToken', () => {
|
||||||
it('should execute DELETE and INSERT queries', async () => {
|
it('should execute DELETE and INSERT queries', async () => {
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger);
|
await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient);
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockClient.query).toHaveBeenCalledWith(
|
||||||
'DELETE FROM public.password_reset_tokens WHERE user_id = $1',
|
'DELETE FROM public.password_reset_tokens WHERE user_id = $1',
|
||||||
['123'],
|
['123'],
|
||||||
);
|
);
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockClient.query).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('INSERT INTO public.password_reset_tokens'),
|
expect.stringContaining('INSERT INTO public.password_reset_tokens'),
|
||||||
['123', 'token-hash', expires],
|
['123', 'token-hash', expires],
|
||||||
);
|
);
|
||||||
@@ -712,18 +676,18 @@ describe('User DB Service', () => {
|
|||||||
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
||||||
const dbError = new Error('violates foreign key constraint');
|
const dbError = new Error('violates foreign key constraint');
|
||||||
(dbError as Error & { code: string }).code = '23503';
|
(dbError as Error & { code: string }).code = '23503';
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger),
|
userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger, mockClient as unknown as PoolClient),
|
||||||
).rejects.toThrow(ForeignKeyConstraintError);
|
).rejects.toThrow(ForeignKeyConstraintError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a generic error if the database query fails', async () => {
|
it('should throw a generic error if the database query fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger),
|
userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient),
|
||||||
).rejects.toThrow('Failed to create password reset token.');
|
).rejects.toThrow('Failed to create password reset token.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, userId: '123' },
|
{ err: dbError, userId: '123' },
|
||||||
@@ -764,10 +728,13 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error if the database query fails', async () => {
|
it('should log an error if the database query fails', async () => {
|
||||||
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
|
const dbError = new Error('DB Error');
|
||||||
await userRepo.deleteResetToken('token-hash', mockLogger);
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
await expect(userRepo.deleteResetToken('token-hash', mockLogger)).rejects.toThrow(
|
||||||
|
'Failed to delete password reset token.',
|
||||||
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: expect.any(Error), tokenHash: 'token-hash' },
|
{ err: dbError, tokenHash: 'token-hash' },
|
||||||
'Database error in deleteResetToken',
|
'Database error in deleteResetToken',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -800,18 +767,7 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('exportUserData', () => {
|
describe('exportUserData', () => {
|
||||||
// Import the mocked withTransaction helper
|
|
||||||
let withTransaction: Mock;
|
|
||||||
beforeEach(async () => {
|
|
||||||
const connDb = await import('./connection.db');
|
|
||||||
// Cast to Mock for type-safe access to mock properties
|
|
||||||
withTransaction = connDb.withTransaction as Mock;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call profile, watched items, and shopping list functions', async () => {
|
it('should call profile, watched items, and shopping list functions', async () => {
|
||||||
const { ShoppingRepository } = await import('./shopping.db');
|
|
||||||
const { PersonalizationRepository } = await import('./personalization.db');
|
|
||||||
|
|
||||||
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
||||||
findProfileSpy.mockResolvedValue(
|
findProfileSpy.mockResolvedValue(
|
||||||
createMockUserProfile({ user: createMockUser({ user_id: '123', email: '123@example.com' }) }),
|
createMockUserProfile({ user: createMockUser({ user_id: '123', email: '123@example.com' }) }),
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ export class UserRepository {
|
|||||||
passwordHash: string | null,
|
passwordHash: string | null,
|
||||||
profileData: { full_name?: string; avatar_url?: string },
|
profileData: { full_name?: string; avatar_url?: string },
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
// Allow passing a transactional client
|
||||||
|
client: Pool | PoolClient = this.db,
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
return withTransaction(async (client: PoolClient) => {
|
try {
|
||||||
logger.debug(`[DB createUser] Starting transaction for email: ${email}`);
|
logger.debug(`[DB createUser] Starting user creation for email: ${email}`);
|
||||||
|
|
||||||
// Use 'set_config' to safely pass parameters to a configuration variable.
|
// Use 'set_config' to safely pass parameters to a configuration variable.
|
||||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
||||||
@@ -126,18 +128,12 @@ export class UserRepository {
|
|||||||
|
|
||||||
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
|
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
|
||||||
return fullUserProfile;
|
return fullUserProfile;
|
||||||
}).catch((error) => {
|
} catch (error) {
|
||||||
// Specific handling for unique constraint violation on user creation
|
handleDbError(error, logger, 'Error during createUser', { email }, {
|
||||||
if (error instanceof Error && 'code' in error && (error as any).code === '23505') {
|
|
||||||
logger.warn(`Attempted to create a user with an existing email: ${email}`);
|
|
||||||
throw new UniqueConstraintError('A user with this email address already exists.');
|
|
||||||
}
|
|
||||||
// Fallback to generic handler for all other errors
|
|
||||||
handleDbError(error, logger, 'Error during createUser transaction', { email }, {
|
|
||||||
uniqueMessage: 'A user with this email address already exists.',
|
uniqueMessage: 'A user with this email address already exists.',
|
||||||
defaultMessage: 'Failed to create user in database.',
|
defaultMessage: 'Failed to create user in database.',
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -464,6 +460,7 @@ export class UserRepository {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// This is a non-critical operation, so we just log the error and continue.
|
||||||
logger.error({ err: error }, 'Database error in deleteRefreshToken');
|
logger.error({ err: error }, 'Database error in deleteRefreshToken');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,10 +472,11 @@ export class UserRepository {
|
|||||||
* @param expiresAt The timestamp when the token expires.
|
* @param expiresAt The timestamp when the token expires.
|
||||||
*/
|
*/
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date, logger: Logger): Promise<void> {
|
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date, logger: Logger, client: PoolClient): Promise<void> {
|
||||||
const client = this.db as PoolClient;
|
|
||||||
try {
|
try {
|
||||||
|
// First, remove any existing tokens for the user to ensure they can only have one active reset request.
|
||||||
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
|
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
|
||||||
|
// Then, insert the new token.
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
||||||
[userId, tokenHash, expiresAt]
|
[userId, tokenHash, expiresAt]
|
||||||
@@ -519,10 +517,9 @@ export class UserRepository {
|
|||||||
try {
|
try {
|
||||||
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
|
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
handleDbError(error, logger, 'Database error in deleteResetToken', { tokenHash }, {
|
||||||
{ err: error, tokenHash },
|
defaultMessage: 'Failed to delete password reset token.',
|
||||||
'Database error in deleteResetToken',
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
src/services/flyer.db.ts
Normal file
0
src/services/flyer.db.ts
Normal file
@@ -1,5 +1,5 @@
|
|||||||
// src/services/flyerAiProcessor.server.test.ts
|
// src/services/flyerAiProcessor.server.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||||
import { AiDataValidationError } from './processingErrors';
|
import { AiDataValidationError } from './processingErrors';
|
||||||
import { logger } from './logger.server'; // Keep this import for the logger instance
|
import { logger } from './logger.server'; // Keep this import for the logger instance
|
||||||
@@ -43,6 +43,11 @@ describe('FlyerAiProcessor', () => {
|
|||||||
service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo);
|
service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Ensure env stubs are cleaned up after each test
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it('should call AI service and return validated data on success', async () => {
|
it('should call AI service and return validated data on success', async () => {
|
||||||
const jobData = createMockJobData({});
|
const jobData = createMockJobData({});
|
||||||
const mockAiResponse = {
|
const mockAiResponse = {
|
||||||
@@ -73,64 +78,230 @@ describe('FlyerAiProcessor', () => {
|
|||||||
expect(result.needsReview).toBe(false);
|
expect(result.needsReview).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw AiDataValidationError if AI response has incorrect data structure', async () => {
|
it('should throw an error if getAllMasterItems fails', async () => {
|
||||||
|
// Arrange
|
||||||
const jobData = createMockJobData({});
|
const jobData = createMockJobData({});
|
||||||
// Mock AI to return a structurally invalid response (e.g., items is not an array)
|
const dbError = new Error('Database connection failed');
|
||||||
const invalidResponse = {
|
vi.mocked(mockPersonalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
||||||
store_name: 'Invalid Store',
|
|
||||||
items: 'not-an-array',
|
|
||||||
valid_from: null,
|
|
||||||
valid_to: null,
|
|
||||||
store_address: null,
|
|
||||||
};
|
|
||||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
|
|
||||||
|
|
||||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
|
|
||||||
AiDataValidationError,
|
// Act & Assert
|
||||||
);
|
await expect(
|
||||||
|
service.extractAndValidateData(imagePaths, jobData, logger),
|
||||||
|
).rejects.toThrow(dbError);
|
||||||
|
|
||||||
|
// Verify that the process stops before calling the AI service
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass validation even if store_name is missing', async () => {
|
describe('Validation and Quality Checks', () => {
|
||||||
const jobData = createMockJobData({});
|
it('should pass validation and not flag for review with good quality data', async () => {
|
||||||
const mockAiResponse = {
|
const jobData = createMockJobData({});
|
||||||
store_name: null, // Missing store name
|
const mockAiResponse = {
|
||||||
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Grocery' }],
|
store_name: 'Good Store',
|
||||||
// ADDED to satisfy AiFlyerDataSchema
|
valid_from: '2024-01-01',
|
||||||
valid_from: null,
|
valid_to: '2024-01-07',
|
||||||
valid_to: null,
|
store_address: '123 Good St',
|
||||||
store_address: null,
|
items: [
|
||||||
};
|
{ item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
|
||||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
{ item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' },
|
||||||
const { logger } = await import('./logger.server');
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
// It should not throw, but return the data and log a warning.
|
// With all data present and correct, it should not need a review.
|
||||||
expect(result.data).toEqual(mockAiResponse);
|
expect(result.needsReview).toBe(false);
|
||||||
expect(result.needsReview).toBe(true);
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('missing a store name. The transformer will use a fallback. Flagging for review.'));
|
});
|
||||||
|
|
||||||
|
it('should throw AiDataValidationError if AI response has incorrect data structure', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
// Mock AI to return a structurally invalid response (e.g., items is not an array)
|
||||||
|
const invalidResponse = {
|
||||||
|
store_name: 'Invalid Store',
|
||||||
|
items: 'not-an-array',
|
||||||
|
valid_from: null,
|
||||||
|
valid_to: null,
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
|
||||||
|
AiDataValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if store_name is missing', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: null, // Missing store name
|
||||||
|
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Grocery' }],
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Missing store name'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if items array is empty', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
items: [], // Empty items array
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['No items were extracted'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if item price quality is low', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Priced Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
|
||||||
|
{ item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'B' },
|
||||||
|
{ item: 'Unpriced Item 2', price_in_cents: null, price_display: 'FREE', quantity: '1', category_name: 'C' },
|
||||||
|
], // 1/3 = 33% have price, which is < 50%
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Low price quality (33% of items have a price)'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use a custom price quality threshold from an environment variable', async () => {
|
||||||
|
// Arrange
|
||||||
|
vi.stubEnv('AI_PRICE_QUALITY_THRESHOLD', '0.8'); // Set a stricter threshold (80%)
|
||||||
|
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
|
||||||
|
{ item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' },
|
||||||
|
{ item: 'Priced Item 3', price_in_cents: 399, price_display: '$3.99', quantity: '1', category_name: 'C' },
|
||||||
|
{ item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'D' },
|
||||||
|
], // 3/4 = 75% have price. This is > 50% (default) but < 80% (custom).
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Because 75% < 80%, it should be flagged for review.
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Low price quality (75% of items have a price)'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if validity dates are missing', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: null, // Missing date
|
||||||
|
valid_to: null, // Missing date
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }],
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Missing both valid_from and valid_to dates'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine multiple quality issues in the log', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: null, // Issue 1
|
||||||
|
items: [], // Issue 2
|
||||||
|
valid_from: null, // Issue 3
|
||||||
|
valid_to: null,
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
{ rawData: mockAiResponse, qualityIssues: ['Missing store name', 'No items were extracted', 'Missing both valid_from and valid_to dates'] },
|
||||||
|
'AI response has quality issues. Flagging for review. Issues: Missing store name, No items were extracted, Missing both valid_from and valid_to dates',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass validation even if items array is empty', async () => {
|
it('should pass the userProfileAddress from jobData to the AI service', async () => {
|
||||||
const jobData = createMockJobData({});
|
// Arrange
|
||||||
|
const jobData = createMockJobData({ userProfileAddress: '456 Fallback Ave' });
|
||||||
const mockAiResponse = {
|
const mockAiResponse = {
|
||||||
store_name: 'Test Store',
|
store_name: 'Test Store',
|
||||||
items: [], // Empty items array
|
valid_from: '2024-01-01',
|
||||||
// ADDED to satisfy AiFlyerDataSchema
|
valid_to: '2024-01-07',
|
||||||
valid_from: null,
|
store_address: '123 Test St',
|
||||||
valid_to: null,
|
items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }],
|
||||||
store_address: null,
|
|
||||||
};
|
};
|
||||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
const { logger } = await import('./logger.server');
|
|
||||||
|
|
||||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
expect(result.data).toEqual(mockAiResponse);
|
|
||||||
expect(result.needsReview).toBe(true);
|
// Assert
|
||||||
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.'));
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
|
||||||
|
imagePaths, [], undefined, '456 Fallback Ave', logger
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Batching Logic', () => {
|
describe('Batching Logic', () => {
|
||||||
@@ -201,6 +372,46 @@ describe('FlyerAiProcessor', () => {
|
|||||||
expect(result.needsReview).toBe(false);
|
expect(result.needsReview).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle an empty object response from a batch without crashing', async () => {
|
||||||
|
// Arrange
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const imagePaths = [
|
||||||
|
{ path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAiResponseBatch1 = {
|
||||||
|
store_name: 'Good Store',
|
||||||
|
valid_from: '2025-01-01',
|
||||||
|
valid_to: '2025-01-07',
|
||||||
|
store_address: '123 Good St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// The AI returns an empty object for the second batch.
|
||||||
|
const mockAiResponseBatch2 = {};
|
||||||
|
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage)
|
||||||
|
.mockResolvedValueOnce(mockAiResponseBatch1)
|
||||||
|
.mockResolvedValueOnce(mockAiResponseBatch2 as any); // Use `as any` to bypass strict type check for the test mock
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 1. AI service was called twice.
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// 2. The final data should only contain data from the first batch.
|
||||||
|
expect(result.data.store_name).toBe('Good Store');
|
||||||
|
expect(result.data.items).toHaveLength(1);
|
||||||
|
expect(result.data.items[0].item).toBe('Item A');
|
||||||
|
|
||||||
|
// 3. The process should complete without errors and not be flagged for review if the first batch was good.
|
||||||
|
expect(result.needsReview).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fill in missing metadata from subsequent batches', async () => {
|
it('should fill in missing metadata from subsequent batches', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const jobData = createMockJobData({});
|
const jobData = createMockJobData({});
|
||||||
@@ -226,4 +437,40 @@ describe('FlyerAiProcessor', () => {
|
|||||||
expect(result.data.items).toHaveLength(2);
|
expect(result.data.items).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle a single batch correctly when image count is less than BATCH_SIZE', async () => {
|
||||||
|
// Arrange
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
// 2 images, which is less than the BATCH_SIZE of 4.
|
||||||
|
const imagePaths = [
|
||||||
|
{ path: 'page1.jpg', mimetype: 'image/jpeg' },
|
||||||
|
{ path: 'page2.jpg', mimetype: 'image/jpeg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Single Batch Store',
|
||||||
|
valid_from: '2025-02-01',
|
||||||
|
valid_to: '2025-02-07',
|
||||||
|
store_address: '789 Single St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Item X', price_display: '$10', price_in_cents: 1000, quantity: '1', category_name: 'Cat X', master_item_id: 10 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the AI service to be called only once.
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValueOnce(mockAiResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 1. AI service was called only once.
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 2. Check the arguments for the single call.
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(imagePaths, [], undefined, undefined, logger);
|
||||||
|
|
||||||
|
// 3. Check that the final data matches the single batch's data.
|
||||||
|
expect(result.data).toEqual(mockAiResponse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -46,26 +46,52 @@ export class FlyerAiProcessor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW QUALITY CHECK ---
|
// --- Data Quality Checks ---
|
||||||
// After structural validation, perform semantic quality checks.
|
// After structural validation, perform semantic quality checks to flag low-quality
|
||||||
const { store_name, items } = validationResult.data;
|
// extractions for manual review.
|
||||||
let needsReview = false;
|
const { store_name, items, valid_from, valid_to } = validationResult.data;
|
||||||
|
const qualityIssues: string[] = [];
|
||||||
|
|
||||||
// 1. Check for a valid store name, but don't fail the job.
|
// 1. Check for a store name.
|
||||||
// The data transformer will handle this by assigning a fallback name.
|
|
||||||
if (!store_name || store_name.trim() === '') {
|
if (!store_name || store_name.trim() === '') {
|
||||||
logger.warn({ rawData: extractedData }, 'AI response is missing a store name. The transformer will use a fallback. Flagging for review.');
|
qualityIssues.push('Missing store name');
|
||||||
needsReview = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check that at least one item was extracted, but don't fail the job.
|
// 2. Check that items were extracted.
|
||||||
// An admin can review a flyer with 0 items.
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
logger.warn({ rawData: extractedData }, 'AI response contains no items. The flyer will be saved with an item_count of 0. Flagging for review.');
|
qualityIssues.push('No items were extracted');
|
||||||
needsReview = true;
|
} else {
|
||||||
|
// 3. If items exist, check their quality (e.g., missing prices).
|
||||||
|
// The threshold is configurable via an environment variable, defaulting to 0.5 (50%).
|
||||||
|
const priceQualityThreshold = parseFloat(process.env.AI_PRICE_QUALITY_THRESHOLD || '0.5');
|
||||||
|
|
||||||
|
const itemsWithPrice = items.filter(
|
||||||
|
(item) => item.price_in_cents != null && item.price_in_cents > 0,
|
||||||
|
).length;
|
||||||
|
const priceQualityRatio = itemsWithPrice / items.length;
|
||||||
|
|
||||||
|
if (priceQualityRatio < priceQualityThreshold) {
|
||||||
|
// If the ratio of items with a valid price is below the threshold, flag for review.
|
||||||
|
qualityIssues.push(
|
||||||
|
`Low price quality (${(priceQualityRatio * 100).toFixed(0)}% of items have a price)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`AI extracted ${validationResult.data.items.length} items.`);
|
// 4. Check for flyer validity dates.
|
||||||
|
if (!valid_from && !valid_to) {
|
||||||
|
qualityIssues.push('Missing both valid_from and valid_to dates');
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsReview = qualityIssues.length > 0;
|
||||||
|
if (needsReview) {
|
||||||
|
logger.warn(
|
||||||
|
{ rawData: extractedData, qualityIssues },
|
||||||
|
`AI response has quality issues. Flagging for review. Issues: ${qualityIssues.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`AI extracted ${validationResult.data.items.length} items. Needs Review: ${needsReview}`);
|
||||||
return { data: validationResult.data, needsReview };
|
return { data: validationResult.data, needsReview };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +155,7 @@ export class FlyerAiProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Items: Append all found items to the master list.
|
// 2. Items: Append all found items to the master list.
|
||||||
mergedData.items.push(...batchResult.items);
|
mergedData.items.push(...(batchResult.items || []));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Batch processing complete. Total items extracted: ${mergedData.items.length}`);
|
logger.info(`Batch processing complete. Total items extracted: ${mergedData.items.length}`);
|
||||||
|
|||||||
@@ -292,4 +292,227 @@ describe('FlyerDataTransformer', () => {
|
|||||||
`${expectedFallbackUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
`${expectedFallbackUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('_normalizeItem price parsing', () => {
|
||||||
|
it('should use price_in_cents from AI if it is valid, ignoring price_display', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 'Milk',
|
||||||
|
price_display: '$4.99', // Parsable, but should be ignored
|
||||||
|
price_in_cents: 399, // AI provides a specific (maybe wrong) value
|
||||||
|
quantity: '1L',
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
imagePaths,
|
||||||
|
'file.pdf',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb[0].price_in_cents).toBe(399); // AI's value should be prioritized
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use parsePriceToCents as a fallback if AI price_in_cents is null', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 'Milk',
|
||||||
|
price_display: '$4.99', // Parsable value
|
||||||
|
price_in_cents: null, // AI fails to provide a value
|
||||||
|
quantity: '1L',
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
imagePaths,
|
||||||
|
'file.pdf',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb[0].price_in_cents).toBe(499); // Should be parsed from price_display
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should result in null if both AI price and display price are unparsable', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 'Milk',
|
||||||
|
price_display: 'FREE', // Unparsable
|
||||||
|
price_in_cents: null, // AI provides null
|
||||||
|
quantity: '1L',
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
imagePaths,
|
||||||
|
'file.pdf',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb[0].price_in_cents).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-string values for string fields gracefully by converting them', async () => {
|
||||||
|
// This test verifies that if data with incorrect types bypasses earlier validation,
|
||||||
|
// the transformer is robust enough to convert them to strings instead of crashing.
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Type-Unsafe Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 12345 as any, // Simulate AI returning a number instead of a string
|
||||||
|
price_display: 3.99 as any, // Simulate a number for a string field
|
||||||
|
price_in_cents: 399,
|
||||||
|
quantity: 5 as any, // Simulate a number
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
imagePaths,
|
||||||
|
'file.pdf',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://robust.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb).toHaveLength(1);
|
||||||
|
expect(itemsForDb[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
item: '12345', // Should be converted to string
|
||||||
|
price_display: '3.99', // Should be converted to string
|
||||||
|
quantity: '5', // Should be converted to string
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('needsReview flag handling', () => {
|
||||||
|
it('should set status to "processed" when needsReview is false', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
needsReview: false, // Key part of this test
|
||||||
|
};
|
||||||
|
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { flyerData } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
imagePaths,
|
||||||
|
'file.pdf',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(flyerData.status).toBe('processed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set status to "needs_review" when needsReview is true', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
needsReview: true, // Key part of this test
|
||||||
|
};
|
||||||
|
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { flyerData } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
imagePaths,
|
||||||
|
'file.pdf',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(flyerData.status).toBe('needs_review');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this
|
|||||||
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
|
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
import { TransformationError } from './processingErrors';
|
import { TransformationError } from './processingErrors';
|
||||||
|
import { parsePriceToCents } from '../utils/priceParser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for transforming the validated data from the AI service
|
* This class is responsible for transforming the validated data from the AI service
|
||||||
@@ -21,16 +22,25 @@ export class FlyerDataTransformer {
|
|||||||
private _normalizeItem(
|
private _normalizeItem(
|
||||||
item: z.infer<typeof AiFlyerDataSchema>['items'][number],
|
item: z.infer<typeof AiFlyerDataSchema>['items'][number],
|
||||||
): FlyerItemInsert {
|
): FlyerItemInsert {
|
||||||
|
// If the AI fails to provide `price_in_cents` but provides a parsable `price_display`,
|
||||||
|
// we can use our own parser as a fallback to improve data quality.
|
||||||
|
const priceFromDisplay = parsePriceToCents(item.price_display ?? '');
|
||||||
|
|
||||||
|
// Prioritize the AI's direct `price_in_cents` value, but use the parsed value if the former is null.
|
||||||
|
const finalPriceInCents = item.price_in_cents ?? priceFromDisplay;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
// Use nullish coalescing and trim for robustness.
|
// Use nullish coalescing and trim for robustness.
|
||||||
// An empty or whitespace-only name falls back to 'Unknown Item'.
|
// An empty or whitespace-only name falls back to 'Unknown Item'.
|
||||||
item: (item.item ?? '').trim() || 'Unknown Item',
|
item: (String(item.item ?? '')).trim() || 'Unknown Item',
|
||||||
// Default null/undefined to an empty string and trim.
|
// Default null/undefined to an empty string and trim.
|
||||||
price_display: (item.price_display ?? '').trim(),
|
price_display: (String(item.price_display ?? '')).trim(),
|
||||||
quantity: (item.quantity ?? '').trim(),
|
quantity: (String(item.quantity ?? '')).trim(),
|
||||||
// An empty or whitespace-only category falls back to 'Other/Miscellaneous'.
|
// An empty or whitespace-only category falls back to 'Other/Miscellaneous'.
|
||||||
category_name: (item.category_name ?? '').trim() || 'Other/Miscellaneous',
|
category_name: (String(item.category_name ?? '')).trim() || 'Other/Miscellaneous',
|
||||||
|
// Overwrite price_in_cents with our calculated value.
|
||||||
|
price_in_cents: finalPriceInCents,
|
||||||
// Use nullish coalescing to convert null to undefined for the database.
|
// Use nullish coalescing to convert null to undefined for the database.
|
||||||
master_item_id: item.master_item_id ?? undefined,
|
master_item_id: item.master_item_id ?? undefined,
|
||||||
view_count: 0,
|
view_count: 0,
|
||||||
@@ -38,6 +48,47 @@ export class FlyerDataTransformer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a 64x64 icon for the flyer's first page.
|
||||||
|
* @param firstImage The path to the first image of the flyer.
|
||||||
|
* @param logger The logger instance.
|
||||||
|
* @returns The filename of the generated icon.
|
||||||
|
*/
|
||||||
|
private async _generateIcon(firstImage: string, logger: Logger): Promise<string> {
|
||||||
|
const iconFileName = await generateFlyerIcon(
|
||||||
|
firstImage,
|
||||||
|
path.join(path.dirname(firstImage), 'icons'),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
return iconFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the full public URLs for the flyer image and its icon.
|
||||||
|
* @param firstImage The path to the first image of the flyer.
|
||||||
|
* @param iconFileName The filename of the generated icon.
|
||||||
|
* @param baseUrl The base URL from the job payload.
|
||||||
|
* @param logger The logger instance.
|
||||||
|
* @returns An object containing the full image_url and icon_url.
|
||||||
|
*/
|
||||||
|
private _buildUrls(
|
||||||
|
firstImage: string,
|
||||||
|
iconFileName: string,
|
||||||
|
baseUrl: string | undefined,
|
||||||
|
logger: Logger,
|
||||||
|
): { imageUrl: string; iconUrl: string } {
|
||||||
|
let finalBaseUrl = baseUrl;
|
||||||
|
if (!finalBaseUrl) {
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
finalBaseUrl = `http://localhost:${port}`;
|
||||||
|
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
|
||||||
|
}
|
||||||
|
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
||||||
|
const imageUrl = `${finalBaseUrl}/flyer-images/${path.basename(firstImage)}`;
|
||||||
|
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
||||||
|
return { imageUrl, iconUrl };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms AI-extracted data into database-ready flyer and item records.
|
* Transforms AI-extracted data into database-ready flyer and item records.
|
||||||
* @param extractedData The validated data from the AI.
|
* @param extractedData The validated data from the AI.
|
||||||
@@ -63,11 +114,8 @@ export class FlyerDataTransformer {
|
|||||||
const { data: extractedData, needsReview } = aiResult;
|
const { data: extractedData, needsReview } = aiResult;
|
||||||
|
|
||||||
const firstImage = imagePaths[0].path;
|
const firstImage = imagePaths[0].path;
|
||||||
const iconFileName = await generateFlyerIcon(
|
const iconFileName = await this._generateIcon(firstImage, logger);
|
||||||
firstImage,
|
const { imageUrl, iconUrl } = this._buildUrls(firstImage, iconFileName, baseUrl, logger);
|
||||||
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));
|
||||||
|
|
||||||
@@ -76,23 +124,10 @@ export class FlyerDataTransformer {
|
|||||||
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The baseUrl is passed from the job payload to ensure the worker has the correct environment context.
|
|
||||||
// If it's missing for any reason, we fall back to a sensible default for local development.
|
|
||||||
let finalBaseUrl = baseUrl;
|
|
||||||
if (!finalBaseUrl) {
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
finalBaseUrl = `http://localhost:${port}`;
|
|
||||||
logger.warn(
|
|
||||||
`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
|
||||||
|
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = {
|
||||||
file_name: originalFileName,
|
file_name: originalFileName,
|
||||||
image_url: `${finalBaseUrl}/flyer-images/${path.basename(firstImage)}`,
|
image_url: imageUrl,
|
||||||
icon_url: `${finalBaseUrl}/flyer-images/icons/${iconFileName}`,
|
icon_url: iconUrl,
|
||||||
checksum,
|
checksum,
|
||||||
store_name: storeName,
|
store_name: storeName,
|
||||||
valid_from: extractedData.valid_from,
|
valid_from: extractedData.valid_from,
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ import {
|
|||||||
AiDataValidationError,
|
AiDataValidationError,
|
||||||
PdfConversionError,
|
PdfConversionError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
|
TransformationError,
|
||||||
} from './processingErrors';
|
} from './processingErrors';
|
||||||
|
import { NotFoundError } from './db/errors.db';
|
||||||
import { FlyerFileHandler } from './flyerFileHandler.server';
|
import { FlyerFileHandler } from './flyerFileHandler.server';
|
||||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||||
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
||||||
@@ -52,6 +54,8 @@ vi.mock('./db/flyer.db', () => ({
|
|||||||
vi.mock('./db/index.db', () => ({
|
vi.mock('./db/index.db', () => ({
|
||||||
personalizationRepo: { getAllMasterItems: vi.fn() },
|
personalizationRepo: { getAllMasterItems: vi.fn() },
|
||||||
adminRepo: { logActivity: vi.fn() },
|
adminRepo: { logActivity: vi.fn() },
|
||||||
|
flyerRepo: { getFlyerById: vi.fn() },
|
||||||
|
withTransaction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock('./logger.server', () => ({
|
vi.mock('./logger.server', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -78,6 +82,10 @@ describe('FlyerProcessingService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Provide a default mock implementation for withTransaction that just executes the callback.
|
||||||
|
// This is needed for the happy path tests. Tests for transaction failures will override this.
|
||||||
|
vi.mocked(mockedDb.withTransaction).mockImplementation(async (callback: any) => callback({}));
|
||||||
|
|
||||||
// Spy on the real transformer's method and provide a mock implementation.
|
// Spy on the real transformer's method and provide a mock implementation.
|
||||||
// This is more robust than mocking the entire class constructor.
|
// This is more robust than mocking the entire class constructor.
|
||||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
||||||
@@ -114,7 +122,6 @@ describe('FlyerProcessingService', () => {
|
|||||||
service = new FlyerProcessingService(
|
service = new FlyerProcessingService(
|
||||||
mockFileHandler,
|
mockFileHandler,
|
||||||
mockAiProcessor,
|
mockAiProcessor,
|
||||||
mockedDb,
|
|
||||||
mockFs,
|
mockFs,
|
||||||
mockCleanupQueue,
|
mockCleanupQueue,
|
||||||
new FlyerDataTransformer(),
|
new FlyerDataTransformer(),
|
||||||
@@ -194,6 +201,9 @@ describe('FlyerProcessingService', () => {
|
|||||||
expect(result).toEqual({ flyerId: 1 });
|
expect(result).toEqual({ flyerId: 1 });
|
||||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
|
||||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify that the transaction function was called.
|
||||||
|
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify that the functions inside the transaction were called.
|
||||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
|
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
|
||||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||||
@@ -215,6 +225,8 @@ describe('FlyerProcessingService', () => {
|
|||||||
|
|
||||||
await service.processJob(job);
|
await service.processJob(job);
|
||||||
|
|
||||||
|
// Verify transaction and inner calls
|
||||||
|
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
|
||||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
@@ -362,6 +374,8 @@ describe('FlyerProcessingService', () => {
|
|||||||
|
|
||||||
await service.processJob(job);
|
await service.processJob(job);
|
||||||
|
|
||||||
|
// Verify transaction and inner calls
|
||||||
|
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
|
||||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||||
@@ -375,20 +389,25 @@ describe('FlyerProcessingService', () => {
|
|||||||
const job = createMockJob({});
|
const job = createMockJob({});
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
const dbError = new Error('Database transaction failed');
|
const dbError = new Error('Database transaction failed');
|
||||||
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
|
|
||||||
|
|
||||||
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
|
// To test the DB failure, we make the transaction itself fail when called.
|
||||||
|
// This is more realistic than mocking the inner function `createFlyerAndItems`.
|
||||||
|
vi.mocked(mockedDb.withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
// The service wraps the generic DB error in a DatabaseError, but _reportErrorAndThrow re-throws the original.
|
||||||
errorCode: 'UNKNOWN_ERROR',
|
await expect(service.processJob(job)).rejects.toThrow(dbError);
|
||||||
message: 'Database transaction failed',
|
|
||||||
|
// The final progress update should reflect the structured DatabaseError.
|
||||||
|
expect(job.updateProgress).toHaveBeenLastCalledWith({
|
||||||
|
errorCode: 'DATABASE_ERROR',
|
||||||
|
message: 'A database operation failed. Please try again later.',
|
||||||
stages: [
|
stages: [
|
||||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||||
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
|
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
|
||||||
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
||||||
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'Database transaction failed' },
|
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'A database operation failed. Please try again later.' },
|
||||||
],
|
],
|
||||||
}); // This was a duplicate, fixed.
|
});
|
||||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||||
@@ -418,17 +437,17 @@ describe('FlyerProcessingService', () => {
|
|||||||
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
|
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
|
||||||
const job = createMockJob({});
|
const job = createMockJob({});
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
const iconError = new Error('Icon generation failed.');
|
const transformationError = new TransformationError('Icon generation failed.');
|
||||||
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
|
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
|
||||||
// to always succeed. For this test, we override that mock to simulate a failure
|
// to always succeed. For this test, we override that mock to simulate a failure
|
||||||
// bubbling up from the icon generation step.
|
// bubbling up from the icon generation step.
|
||||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(iconError);
|
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(transformationError);
|
||||||
|
|
||||||
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
||||||
|
|
||||||
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
||||||
|
|
||||||
expect(reportErrorSpy).toHaveBeenCalledWith(iconError, job, expect.any(Object), expect.any(Array));
|
expect(reportErrorSpy).toHaveBeenCalledWith(transformationError, job, expect.any(Object), expect.any(Array));
|
||||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||||
@@ -589,14 +608,23 @@ describe('FlyerProcessingService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip processing and return "skipped" if paths array is empty', async () => {
|
it('should skip processing and return "skipped" if paths array is empty and paths cannot be derived', async () => {
|
||||||
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
|
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
|
||||||
|
// Mock that the flyer cannot be found in the DB, so paths cannot be derived.
|
||||||
|
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError('Not found'));
|
||||||
|
|
||||||
const result = await service.processCleanupJob(job);
|
const result = await service.processCleanupJob(job);
|
||||||
|
|
||||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ status: 'skipped', reason: 'no paths' });
|
expect(result).toEqual({ status: 'skipped', reason: 'no paths derived' });
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('Job received no paths to clean. Skipping.');
|
// Check for both warnings: the attempt to derive, and the final skip message.
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Job received no paths and could not derive any from the database. Skipping.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/services/flyerProcessingService.server.ts
|
// src/services/flyerProcessingService.server.ts
|
||||||
import type { Job, Queue } from 'bullmq';
|
import type { Job, Queue } from 'bullmq';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import { UnrecoverableError } from 'bullmq';
|
||||||
|
import path from 'path';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import type { FlyerFileHandler, IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
import type { FlyerFileHandler, IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
||||||
import type { FlyerAiProcessor } from './flyerAiProcessor.server';
|
import type { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||||
import type * as Db from './db/index.db';
|
import * as db from './db/index.db';
|
||||||
import type { AdminRepository } from './db/admin.db';
|
import { AdminRepository } from './db/admin.db';
|
||||||
import { FlyerDataTransformer } from './flyerDataTransformer';
|
import { FlyerDataTransformer } from './flyerDataTransformer';
|
||||||
import type { FlyerJobData, CleanupJobData } from '../types/job-data';
|
import type { FlyerJobData, CleanupJobData } from '../types/job-data';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,9 @@ import {
|
|||||||
PdfConversionError,
|
PdfConversionError,
|
||||||
AiDataValidationError,
|
AiDataValidationError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
|
DatabaseError, // This is from processingErrors
|
||||||
} from './processingErrors';
|
} from './processingErrors';
|
||||||
|
import { NotFoundError } from './db/errors.db';
|
||||||
import { createFlyerAndItems } from './db/flyer.db';
|
import { createFlyerAndItems } from './db/flyer.db';
|
||||||
import { logger as globalLogger } from './logger.server';
|
import { logger as globalLogger } from './logger.server';
|
||||||
|
|
||||||
@@ -34,9 +37,6 @@ export class FlyerProcessingService {
|
|||||||
constructor(
|
constructor(
|
||||||
private fileHandler: FlyerFileHandler,
|
private fileHandler: FlyerFileHandler,
|
||||||
private aiProcessor: FlyerAiProcessor,
|
private aiProcessor: FlyerAiProcessor,
|
||||||
// This service only needs the `logActivity` method from the `adminRepo`.
|
|
||||||
// By using `Pick`, we create a more focused and testable dependency.
|
|
||||||
private db: { adminRepo: Pick<AdminRepository, 'logActivity'> },
|
|
||||||
private fs: IFileSystem,
|
private fs: IFileSystem,
|
||||||
// By depending on `Pick<Queue, 'add'>`, we specify that this service only needs
|
// By depending on `Pick<Queue, 'add'>`, we specify that this service only needs
|
||||||
// an object with an `add` method that matches the Queue's `add` method signature.
|
// an object with an `add` method that matches the Queue's `add` method signature.
|
||||||
@@ -108,21 +108,29 @@ export class FlyerProcessingService {
|
|||||||
stages[3].status = 'in-progress';
|
stages[3].status = 'in-progress';
|
||||||
await job.updateProgress({ stages });
|
await job.updateProgress({ stages });
|
||||||
|
|
||||||
const { flyer } = await createFlyerAndItems(flyerData, itemsForDb, logger);
|
const { flyer } = await db.withTransaction(async (client) => {
|
||||||
|
// This assumes createFlyerAndItems is refactored to accept a transactional client.
|
||||||
|
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||||
|
|
||||||
|
// Instantiate a new AdminRepository with the transactional client to ensure
|
||||||
|
// the activity log is part of the same transaction.
|
||||||
|
const transactionalAdminRepo = new AdminRepository(client);
|
||||||
|
await transactionalAdminRepo.logActivity(
|
||||||
|
{
|
||||||
|
action: 'flyer_processed',
|
||||||
|
displayText: `Processed flyer for ${flyerData.store_name}`,
|
||||||
|
details: { flyer_id: newFlyer.flyer_id, store_name: flyerData.store_name },
|
||||||
|
userId: job.data.userId,
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { flyer: newFlyer };
|
||||||
|
});
|
||||||
|
|
||||||
stages[3].status = 'completed';
|
stages[3].status = 'completed';
|
||||||
await job.updateProgress({ stages });
|
await job.updateProgress({ stages });
|
||||||
|
|
||||||
// Stage 5: Log Activity
|
|
||||||
await this.db.adminRepo.logActivity(
|
|
||||||
{
|
|
||||||
action: 'flyer_processed',
|
|
||||||
displayText: `Processed flyer for ${flyerData.store_name}`,
|
|
||||||
details: { flyer_id: flyer.flyer_id, store_name: flyerData.store_name },
|
|
||||||
userId: job.data.userId,
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enqueue a job to clean up the original and any generated files.
|
// Enqueue a job to clean up the original and any generated files.
|
||||||
await this.cleanupQueue.add(
|
await this.cleanupQueue.add(
|
||||||
'cleanup-flyer-files',
|
'cleanup-flyer-files',
|
||||||
@@ -157,14 +165,51 @@ export class FlyerProcessingService {
|
|||||||
const logger = globalLogger.child({ jobId: job.id, jobName: job.name, ...job.data });
|
const logger = globalLogger.child({ jobId: job.id, jobName: job.name, ...job.data });
|
||||||
logger.info('Picked up file cleanup job.');
|
logger.info('Picked up file cleanup job.');
|
||||||
|
|
||||||
const { paths } = job.data;
|
const { flyerId, paths } = job.data;
|
||||||
if (!paths || paths.length === 0) {
|
let pathsToDelete = paths;
|
||||||
logger.warn('Job received no paths to clean. Skipping.');
|
|
||||||
return { status: 'skipped', reason: 'no paths' };
|
// If no paths are provided (e.g., from a manual trigger), attempt to derive them from the database.
|
||||||
|
if (!pathsToDelete || pathsToDelete.length === 0) {
|
||||||
|
logger.warn(`Cleanup job for flyer ${flyerId} received no paths. Attempting to derive paths from DB.`);
|
||||||
|
try {
|
||||||
|
const flyer = await db.flyerRepo.getFlyerById(flyerId);
|
||||||
|
const derivedPaths: string[] = [];
|
||||||
|
// This path needs to be configurable and match where multer saves files.
|
||||||
|
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||||
|
|
||||||
|
if (flyer.image_url) {
|
||||||
|
try {
|
||||||
|
const imageName = path.basename(new URL(flyer.image_url).pathname);
|
||||||
|
derivedPaths.push(path.join(storagePath, imageName));
|
||||||
|
} catch (urlError) {
|
||||||
|
logger.error({ err: urlError, url: flyer.image_url }, 'Failed to parse flyer.image_url to derive file path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flyer.icon_url) {
|
||||||
|
try {
|
||||||
|
const iconName = path.basename(new URL(flyer.icon_url).pathname);
|
||||||
|
derivedPaths.push(path.join(storagePath, 'icons', iconName));
|
||||||
|
} catch (urlError) {
|
||||||
|
logger.error({ err: urlError, url: flyer.icon_url }, 'Failed to parse flyer.icon_url to derive file path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pathsToDelete = derivedPaths;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError) {
|
||||||
|
logger.error({ flyerId }, 'Cannot derive cleanup paths because flyer was not found in DB.');
|
||||||
|
throw new UnrecoverableError(`Cleanup failed: Flyer with ID ${flyerId} not found.`);
|
||||||
|
}
|
||||||
|
throw error; // Re-throw other DB errors to allow for retries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathsToDelete || pathsToDelete.length === 0) {
|
||||||
|
logger.warn('Job received no paths and could not derive any from the database. Skipping.');
|
||||||
|
return { status: 'skipped', reason: 'no paths derived' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
paths.map(async (filePath) => {
|
pathsToDelete.map(async (filePath) => {
|
||||||
try {
|
try {
|
||||||
await this.fs.unlink(filePath);
|
await this.fs.unlink(filePath);
|
||||||
logger.info(`Successfully deleted temporary file: ${filePath}`);
|
logger.info(`Successfully deleted temporary file: ${filePath}`);
|
||||||
@@ -183,12 +228,12 @@ export class FlyerProcessingService {
|
|||||||
|
|
||||||
const failedDeletions = results.filter((r) => r.status === 'rejected');
|
const failedDeletions = results.filter((r) => r.status === 'rejected');
|
||||||
if (failedDeletions.length > 0) {
|
if (failedDeletions.length > 0) {
|
||||||
const failedPaths = paths.filter((_, i) => results[i].status === 'rejected');
|
const failedPaths = pathsToDelete.filter((_, i) => results[i].status === 'rejected');
|
||||||
throw new Error(`Failed to delete ${failedDeletions.length} file(s): ${failedPaths.join(', ')}`);
|
throw new Error(`Failed to delete ${failedDeletions.length} file(s): ${failedPaths.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Successfully deleted all ${paths.length} temporary files.`);
|
logger.info(`Successfully deleted all ${pathsToDelete.length} temporary files.`);
|
||||||
return { status: 'success', deletedCount: paths.length };
|
return { status: 'success', deletedCount: pathsToDelete.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +255,8 @@ export class FlyerProcessingService {
|
|||||||
['PDF_CONVERSION_FAILED', 'Preparing Inputs'],
|
['PDF_CONVERSION_FAILED', 'Preparing Inputs'],
|
||||||
['UNSUPPORTED_FILE_TYPE', 'Preparing Inputs'],
|
['UNSUPPORTED_FILE_TYPE', 'Preparing Inputs'],
|
||||||
['AI_VALIDATION_FAILED', 'Extracting Data with AI'],
|
['AI_VALIDATION_FAILED', 'Extracting Data with AI'],
|
||||||
['TRANSFORMATION_FAILED', 'Transforming AI Data'], // Add new mapping
|
['TRANSFORMATION_FAILED', 'Transforming AI Data'],
|
||||||
|
['DATABASE_ERROR', 'Saving to Database'],
|
||||||
]);
|
]);
|
||||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||||
let errorPayload: { errorCode: string; message: string; [key: string]: any };
|
let errorPayload: { errorCode: string; message: string; [key: string]: any };
|
||||||
@@ -227,15 +273,6 @@ export class FlyerProcessingService {
|
|||||||
const failedStageName = errorCodeToStageMap.get(errorPayload.errorCode);
|
const failedStageName = errorCodeToStageMap.get(errorPayload.errorCode);
|
||||||
let errorStageIndex = failedStageName ? stagesToReport.findIndex(s => s.name === failedStageName) : -1;
|
let errorStageIndex = failedStageName ? stagesToReport.findIndex(s => s.name === failedStageName) : -1;
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
if (errorStageIndex === -1 && errorPayload.message.includes('Database transaction failed')) {
|
|
||||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Saving to Database');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If not mapped, find the currently running stage
|
// 2. If not mapped, find the currently running stage
|
||||||
if (errorStageIndex === -1) {
|
if (errorStageIndex === -1) {
|
||||||
errorStageIndex = stagesToReport.findIndex(s => s.status === 'in-progress');
|
errorStageIndex = stagesToReport.findIndex(s => s.status === 'in-progress');
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/services/gamificationService.ts
|
// src/services/gamificationService.ts
|
||||||
|
|
||||||
import { gamificationRepo } from './db/index.db';
|
import { gamificationRepo } from './db/index.db';
|
||||||
import { ForeignKeyConstraintError } from './db/errors.db';
|
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
|
import { ForeignKeyConstraintError } from './db/errors.db';
|
||||||
|
|
||||||
class GamificationService {
|
class GamificationService {
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +16,12 @@ class GamificationService {
|
|||||||
await gamificationRepo.awardAchievement(userId, achievementName, log);
|
await gamificationRepo.awardAchievement(userId, achievementName, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForeignKeyConstraintError) {
|
if (error instanceof ForeignKeyConstraintError) {
|
||||||
|
// This is an expected error (e.g., achievement name doesn't exist),
|
||||||
|
// which the repository layer should have already logged with appropriate context.
|
||||||
|
// We re-throw it so the calling layer (e.g., an admin route) can handle it.
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// For unexpected, generic errors, we log them at the service level before re-throwing.
|
||||||
log.error(
|
log.error(
|
||||||
{ error, userId, achievementName },
|
{ error, userId, achievementName },
|
||||||
'Error awarding achievement via admin endpoint:',
|
'Error awarding achievement via admin endpoint:',
|
||||||
@@ -45,10 +49,6 @@ class GamificationService {
|
|||||||
* @param log The logger instance.
|
* @param log The logger instance.
|
||||||
*/
|
*/
|
||||||
async getLeaderboard(limit: number, log: Logger) {
|
async getLeaderboard(limit: number, log: Logger) {
|
||||||
// The test failures point to an issue in the underlying repository method,
|
|
||||||
// where the database query is not being executed. This service method is a simple
|
|
||||||
// pass-through, so the root cause is likely in `gamification.db.ts`.
|
|
||||||
// Adding robust error handling here is a good practice regardless.
|
|
||||||
try {
|
try {
|
||||||
return await gamificationRepo.getLeaderboard(limit, log);
|
return await gamificationRepo.getLeaderboard(limit, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -63,10 +63,6 @@ class GamificationService {
|
|||||||
* @param log The logger instance.
|
* @param log The logger instance.
|
||||||
*/
|
*/
|
||||||
async getUserAchievements(userId: string, log: Logger) {
|
async getUserAchievements(userId: string, log: Logger) {
|
||||||
// The test failures point to an issue in the underlying repository method,
|
|
||||||
// where the database query is not being executed. This service method is a simple
|
|
||||||
// pass-through, so the root cause is likely in `gamification.db.ts`.
|
|
||||||
// Adding robust error handling here is a good practice regardless.
|
|
||||||
try {
|
try {
|
||||||
return await gamificationRepo.getUserAchievements(userId, log);
|
return await gamificationRepo.getUserAchievements(userId, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ export class TransformationError extends FlyerProcessingError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when a database operation fails during processing.
|
||||||
|
*/
|
||||||
|
export class DatabaseError extends FlyerProcessingError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
'DATABASE_ERROR',
|
||||||
|
'A database operation failed. Please try again later.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Error thrown when an image conversion fails (e.g., using sharp).
|
* Error thrown when an image conversion fails (e.g., using sharp).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Address, UserProfile } from '../types';
|
|||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { ValidationError, NotFoundError } from './db/errors.db';
|
import { ValidationError, NotFoundError } from './db/errors.db';
|
||||||
|
import { DatabaseError } from './processingErrors';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import type { TokenCleanupJobData } from '../types/job-data';
|
import type { TokenCleanupJobData } from '../types/job-data';
|
||||||
|
|
||||||
@@ -176,6 +177,29 @@ describe('UserService', () => {
|
|||||||
// 3. Since the address ID did not change, the user profile should NOT be updated.
|
// 3. Since the address ID did not change, the user profile should NOT be updated.
|
||||||
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
|
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw a DatabaseError if the transaction fails', async () => {
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
const user = createMockUserProfile({
|
||||||
|
user: { user_id: 'user-123' },
|
||||||
|
address_id: null,
|
||||||
|
});
|
||||||
|
const addressData: Partial<Address> = { address_line_1: '123 Fail St' };
|
||||||
|
const dbError = new Error('DB connection lost');
|
||||||
|
|
||||||
|
// Simulate a failure within the transaction (e.g., upsertAddress fails)
|
||||||
|
mocks.mockUpsertAddress.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
// The service should wrap the generic error in a `DatabaseError`.
|
||||||
|
await expect(userService.upsertUserAddress(user, addressData, logger)).rejects.toBeInstanceOf(DatabaseError);
|
||||||
|
|
||||||
|
// Assert that the error was logged correctly
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ err: dbError, userId: user.user.user_id },
|
||||||
|
`Transaction to upsert user address failed: ${dbError.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('processTokenCleanupJob', () => {
|
describe('processTokenCleanupJob', () => {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { AddressRepository } from './db/address.db';
|
|||||||
import { UserRepository } from './db/user.db';
|
import { UserRepository } from './db/user.db';
|
||||||
import type { Address, Profile, UserProfile } from '../types';
|
import type { Address, Profile, UserProfile } from '../types';
|
||||||
import { ValidationError, NotFoundError } from './db/errors.db';
|
import { ValidationError, NotFoundError } from './db/errors.db';
|
||||||
|
import { DatabaseError } from './processingErrors';
|
||||||
import { logger as globalLogger } from './logger.server';
|
import { logger as globalLogger } from './logger.server';
|
||||||
import type { TokenCleanupJobData } from '../types/job-data';
|
import type { TokenCleanupJobData } from '../types/job-data';
|
||||||
|
import { getBaseUrl } from '../utils/serverUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates user-related business logic that may involve multiple repository calls.
|
* Encapsulates user-related business logic that may involve multiple repository calls.
|
||||||
@@ -27,27 +29,26 @@ class UserService {
|
|||||||
addressData: Partial<Address>,
|
addressData: Partial<Address>,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
return db.withTransaction(async (client) => {
|
return db
|
||||||
// Instantiate repositories with the transactional client
|
.withTransaction(async (client) => {
|
||||||
const addressRepo = new AddressRepository(client);
|
const addressRepo = new AddressRepository(client);
|
||||||
const userRepo = new UserRepository(client);
|
const userRepo = new UserRepository(client);
|
||||||
|
const addressId = await addressRepo.upsertAddress(
|
||||||
const addressId = await addressRepo.upsertAddress(
|
{ ...addressData, address_id: userprofile.address_id ?? undefined },
|
||||||
{ ...addressData, address_id: userprofile.address_id ?? undefined },
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the user didn't have an address_id before, update their profile to link it.
|
|
||||||
if (!userprofile.address_id) {
|
|
||||||
await userRepo.updateUserProfile(
|
|
||||||
userprofile.user.user_id,
|
|
||||||
{ address_id: addressId },
|
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
}
|
if (!userprofile.address_id) {
|
||||||
|
await userRepo.updateUserProfile(userprofile.user.user_id, { address_id: addressId }, logger);
|
||||||
return addressId;
|
}
|
||||||
});
|
return addressId;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId: userprofile.user.user_id }, `Transaction to upsert user address failed: ${errorMessage}`);
|
||||||
|
// Wrap the original error in a service-level DatabaseError to standardize the error contract,
|
||||||
|
// as this is an unexpected failure within the transaction boundary.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,27 +56,21 @@ class UserService {
|
|||||||
* @param job The BullMQ job object.
|
* @param job The BullMQ job object.
|
||||||
* @returns An object containing the count of deleted tokens.
|
* @returns An object containing the count of deleted tokens.
|
||||||
*/
|
*/
|
||||||
async processTokenCleanupJob(
|
async processTokenCleanupJob(job: Job<TokenCleanupJobData>): Promise<{ deletedCount: number }> {
|
||||||
job: Job<TokenCleanupJobData>,
|
|
||||||
): Promise<{ deletedCount: number }> {
|
|
||||||
const logger = globalLogger.child({
|
const logger = globalLogger.child({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
jobName: job.name,
|
jobName: job.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Picked up expired token cleanup job.');
|
logger.info('Picked up expired token cleanup job.');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedCount = await db.userRepo.deleteExpiredResetTokens(logger);
|
const deletedCount = await db.userRepo.deleteExpiredResetTokens(logger);
|
||||||
logger.info(`Successfully deleted ${deletedCount} expired tokens.`);
|
logger.info(`Successfully deleted ${deletedCount} expired tokens.`);
|
||||||
return { deletedCount };
|
return { deletedCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wrappedError = error instanceof Error ? error : new Error(String(error));
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
logger.error(
|
logger.error({ err: error, attemptsMade: job.attemptsMade }, `Expired token cleanup job failed: ${errorMessage}`);
|
||||||
{ err: wrappedError, attemptsMade: job.attemptsMade },
|
// This is a background job, but wrapping in a standard error type is good practice.
|
||||||
'Expired token cleanup job failed.',
|
throw new DatabaseError(errorMessage);
|
||||||
);
|
|
||||||
throw wrappedError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,26 +82,20 @@ class UserService {
|
|||||||
* @returns The updated user profile.
|
* @returns The updated user profile.
|
||||||
*/
|
*/
|
||||||
async updateUserAvatar(userId: string, file: Express.Multer.File, logger: Logger): Promise<Profile> {
|
async updateUserAvatar(userId: string, file: Express.Multer.File, logger: Logger): Promise<Profile> {
|
||||||
// Construct proper URLs including protocol and host to satisfy DB constraints.
|
try {
|
||||||
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
|
const baseUrl = getBaseUrl(logger);
|
||||||
if (!baseUrl || !baseUrl.startsWith('http')) {
|
const avatarUrl = `${baseUrl}/uploads/avatars/${file.filename}`;
|
||||||
const port = process.env.PORT || 3000;
|
return await db.userRepo.updateUserProfile(userId, { avatar_url: avatarUrl }, logger);
|
||||||
const fallbackUrl = `http://localhost:${port}`;
|
} catch (error) {
|
||||||
if (baseUrl) {
|
// Re-throw known application errors without logging them as system errors.
|
||||||
logger.warn(
|
if (error instanceof NotFoundError) {
|
||||||
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
|
throw error;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
baseUrl = fallbackUrl;
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId }, `Failed to update user avatar: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
|
|
||||||
const avatarUrl = `${baseUrl}/uploads/avatars/${file.filename}`;
|
|
||||||
return db.userRepo.updateUserProfile(
|
|
||||||
userId,
|
|
||||||
{ avatar_url: avatarUrl },
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Updates a user's password after hashing it.
|
* Updates a user's password after hashing it.
|
||||||
@@ -115,9 +104,16 @@ class UserService {
|
|||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
*/
|
*/
|
||||||
async updateUserPassword(userId: string, newPassword: string, logger: Logger): Promise<void> {
|
async updateUserPassword(userId: string, newPassword: string, logger: Logger): Promise<void> {
|
||||||
const saltRounds = 10;
|
try {
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
const saltRounds = 10;
|
||||||
await db.userRepo.updateUserPassword(userId, hashedPassword, logger);
|
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
await db.userRepo.updateUserPassword(userId, hashedPassword, logger);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId }, `Failed to update user password: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,19 +123,25 @@ class UserService {
|
|||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
*/
|
*/
|
||||||
async deleteUserAccount(userId: string, password: string, logger: Logger): Promise<void> {
|
async deleteUserAccount(userId: string, password: string, logger: Logger): Promise<void> {
|
||||||
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userId, logger);
|
try {
|
||||||
if (!userWithHash || !userWithHash.password_hash) {
|
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userId, logger);
|
||||||
// This case should be rare for a logged-in user but is a good safeguard.
|
if (!userWithHash || !userWithHash.password_hash) {
|
||||||
throw new NotFoundError('User not found or password not set.');
|
throw new NotFoundError('User not found or password not set.');
|
||||||
|
}
|
||||||
|
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new ValidationError([], 'Incorrect password.');
|
||||||
|
}
|
||||||
|
await db.userRepo.deleteUserById(userId, logger);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId }, `Failed to delete user account: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
|
||||||
if (!isMatch) {
|
|
||||||
// Use ValidationError for a 400-level response in the route
|
|
||||||
throw new ValidationError([], 'Incorrect password.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.userRepo.deleteUserById(userId, logger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,18 +151,21 @@ class UserService {
|
|||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
* @returns The address object.
|
* @returns The address object.
|
||||||
*/
|
*/
|
||||||
async getUserAddress(
|
async getUserAddress(userProfile: UserProfile, addressId: number, logger: Logger): Promise<Address> {
|
||||||
userProfile: UserProfile,
|
|
||||||
addressId: number,
|
|
||||||
logger: Logger,
|
|
||||||
): Promise<Address> {
|
|
||||||
// Security check: Ensure the requested addressId matches the one on the user's profile.
|
|
||||||
if (userProfile.address_id !== addressId) {
|
if (userProfile.address_id !== addressId) {
|
||||||
// Use ValidationError to trigger a 403 Forbidden response in the route handler.
|
|
||||||
throw new ValidationError([], 'Forbidden: You can only access your own address.');
|
throw new ValidationError([], 'Forbidden: You can only access your own address.');
|
||||||
}
|
}
|
||||||
// The repo method will throw a NotFoundError if the address doesn't exist.
|
try {
|
||||||
return db.addressRepo.getAddressById(addressId, logger);
|
return await db.addressRepo.getAddressById(addressId, logger);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId: userProfile.user.user_id, addressId }, `Failed to get user address: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +179,17 @@ class UserService {
|
|||||||
if (deleterId === userToDeleteId) {
|
if (deleterId === userToDeleteId) {
|
||||||
throw new ValidationError([], 'Admins cannot delete their own account.');
|
throw new ValidationError([], 'Admins cannot delete their own account.');
|
||||||
}
|
}
|
||||||
await db.userRepo.deleteUserById(userToDeleteId, log);
|
try {
|
||||||
|
await db.userRepo.deleteUserById(userToDeleteId, log);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
log.error({ err: error, deleterId, userToDeleteId }, `Admin failed to delete user account: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ const fsAdapter: IFileSystem = {
|
|||||||
const flyerProcessingService = new FlyerProcessingService(
|
const flyerProcessingService = new FlyerProcessingService(
|
||||||
new FlyerFileHandler(fsAdapter, execAsync),
|
new FlyerFileHandler(fsAdapter, execAsync),
|
||||||
new FlyerAiProcessor(aiService, db.personalizationRepo),
|
new FlyerAiProcessor(aiService, db.personalizationRepo),
|
||||||
db,
|
|
||||||
fsAdapter,
|
fsAdapter,
|
||||||
cleanupQueue,
|
cleanupQueue,
|
||||||
new FlyerDataTransformer(),
|
new FlyerDataTransformer(),
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ export const requiredString = (message: string) =>
|
|||||||
// They are used for validation and type inference across multiple services.
|
// They are used for validation and type inference across multiple services.
|
||||||
|
|
||||||
export const ExtractedFlyerItemSchema = z.object({
|
export const ExtractedFlyerItemSchema = z.object({
|
||||||
item: z.string().nullable(),
|
item: z.string().nullish(),
|
||||||
price_display: z.string().nullable(),
|
price_display: z.string().nullish(),
|
||||||
price_in_cents: z.number().nullable(),
|
price_in_cents: z.number().nullish(),
|
||||||
quantity: z.string().nullable(),
|
quantity: z.string().nullish(),
|
||||||
category_name: z.string().nullable(),
|
category_name: z.string().nullish(),
|
||||||
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
|
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,37 @@ export interface FlyerJobData {
|
|||||||
*/
|
*/
|
||||||
export interface CleanupJobData {
|
export interface CleanupJobData {
|
||||||
flyerId: number;
|
flyerId: number;
|
||||||
paths: string[];
|
paths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the shape of the data payload for a token cleanup job.
|
||||||
|
*/
|
||||||
|
export interface TokenCleanupJobData {
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the shape of the data payload for a daily analytics report job.
|
||||||
|
*/
|
||||||
|
export interface AnalyticsJobData {
|
||||||
|
reportDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the shape of the data payload for a weekly analytics report job.
|
||||||
|
*/
|
||||||
|
export interface WeeklyAnalyticsJobData {
|
||||||
|
reportYear: number;
|
||||||
|
reportWeek: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the shape of the data payload for an email sending job.
|
||||||
|
*/
|
||||||
|
export interface EmailJobData {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,16 @@
|
|||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, Mocked } from 'vitest';
|
||||||
import { convertPdfToImageFiles } from './pdfConverter';
|
import { convertPdfToImageFiles } from './pdfConverter';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
|
// Mock the logger before other imports to spy on its methods
|
||||||
|
vi.mock('../services/logger.client', () => ({
|
||||||
|
logger: {
|
||||||
|
warn: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the entire pdfjs-dist library
|
// Mock the entire pdfjs-dist library
|
||||||
const mockPdfPage = {
|
const mockPdfPage = {
|
||||||
@@ -14,7 +22,9 @@ const mockPdfPage = {
|
|||||||
|
|
||||||
const mockPdfDocument = {
|
const mockPdfDocument = {
|
||||||
numPages: 3,
|
numPages: 3,
|
||||||
getPage: vi.fn(() => Promise.resolve(mockPdfPage)),
|
// Explicitly type the mock function to accept a number and return the correct promise type.
|
||||||
|
// This resolves the TypeScript error when using mockImplementation with arguments later.
|
||||||
|
getPage: vi.fn<(pageNumber: number) => Promise<typeof mockPdfPage>>(() => Promise.resolve(mockPdfPage)),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('pdfjs-dist', () => ({
|
vi.mock('pdfjs-dist', () => ({
|
||||||
@@ -205,19 +215,56 @@ describe('pdfConverter', () => {
|
|||||||
expect(getDocument).toHaveBeenCalled();
|
expect(getDocument).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if conversion results in zero images for a non-empty PDF', async () => {
|
it('should throw a specific error if all pages of a non-empty PDF fail to convert', async () => {
|
||||||
// Arrange: Ensure the document appears to have pages
|
// Arrange: Ensure the document appears to have pages
|
||||||
mockPdfDocument.numPages = 1;
|
mockPdfDocument.numPages = 1;
|
||||||
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
|
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||||
|
|
||||||
// Mock getPage to fail for the first page. This simulates a corrupted page
|
// Mock getPage to fail for the only page. This simulates a scenario where
|
||||||
// within an otherwise valid PDF document, which is what the function's
|
// the PDF has pages, but none can be rendered, causing the `imageFiles` array
|
||||||
// Promise.allSettled logic is designed to handle.
|
// to be empty.
|
||||||
vi.mocked(mockPdfDocument.getPage).mockRejectedValueOnce(new Error('Corrupted page'));
|
vi.mocked(mockPdfDocument.getPage).mockRejectedValueOnce(new Error('Corrupted page'));
|
||||||
|
|
||||||
// Act & Assert: The function should catch the settled promise and re-throw the reason.
|
// Act & Assert: The function should now catch the settled promise, find that no
|
||||||
|
// images were generated, and throw the specific "zero images" error, covering line 133.
|
||||||
|
await expect(convertPdfToImageFiles(pdfFile)).rejects.toThrow(
|
||||||
|
'PDF conversion resulted in zero images, though the PDF has pages. It might be corrupted or contain non-standard content.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await expect(convertPdfToImageFiles(pdfFile)).rejects.toThrow('Corrupted page');
|
it('should successfully process a PDF even if some pages fail to convert', async () => {
|
||||||
|
// Arrange: 3-page PDF where the 2nd page will fail
|
||||||
|
mockPdfDocument.numPages = 3;
|
||||||
|
const pdfFile = new File(['pdf-content'], 'partial-success.pdf', { type: 'application/pdf' });
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const mockedLogger = logger as Mocked<typeof logger>;
|
||||||
|
|
||||||
|
// Mock getPage to fail only for the second page
|
||||||
|
vi.mocked(mockPdfDocument.getPage).mockImplementation(async (pageNumber: number) => {
|
||||||
|
if (pageNumber === 2) {
|
||||||
|
throw new Error('Simulated page 2 corruption');
|
||||||
|
}
|
||||||
|
// Return the standard mock page for other pages
|
||||||
|
return mockPdfPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { imageFiles, pageCount } = await convertPdfToImageFiles(pdfFile, onProgress);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Total page count should still be 3
|
||||||
|
expect(pageCount).toBe(3);
|
||||||
|
// Only 2 pages should have converted successfully
|
||||||
|
expect(imageFiles).toHaveLength(2);
|
||||||
|
// The progress callback should have been called for the 2 successful pages
|
||||||
|
expect(onProgress).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onProgress).toHaveBeenCalledWith(1, 3);
|
||||||
|
expect(onProgress).toHaveBeenCalledWith(3, 3);
|
||||||
|
// The failure of page 2 should be logged as a warning
|
||||||
|
expect(mockedLogger.warn).toHaveBeenCalledWith(
|
||||||
|
{ error: new Error('Simulated page 2 corruption') },
|
||||||
|
'A page failed to convert during PDF processing.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if FileReader fails', async () => {
|
it('should throw an error if FileReader fails', async () => {
|
||||||
|
|||||||
@@ -116,17 +116,18 @@ export const convertPdfToImageFiles = async (
|
|||||||
// Process all pages in parallel and collect the results.
|
// Process all pages in parallel and collect the results.
|
||||||
const settledResults = await Promise.allSettled(pagePromises);
|
const settledResults = await Promise.allSettled(pagePromises);
|
||||||
|
|
||||||
// Check for any hard failures and re-throw the first one encountered.
|
// Filter for fulfilled promises and extract their values. This allows for partial
|
||||||
const firstRejected = settledResults.find((r) => r.status === 'rejected') as
|
// success if some pages convert and others fail.
|
||||||
| PromiseRejectedResult
|
const imageFiles = settledResults
|
||||||
| undefined;
|
.filter((result): result is PromiseFulfilledResult<File> => result.status === 'fulfilled')
|
||||||
if (firstRejected) {
|
.map((result) => result.value);
|
||||||
throw firstRejected.reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all successfully rendered image files. Since we've already checked for rejections,
|
// Log any pages that failed to convert, without stopping the entire process.
|
||||||
// we know all results are fulfilled and can safely extract their values.
|
settledResults.forEach((result) => {
|
||||||
const imageFiles = settledResults.map((result) => (result as PromiseFulfilledResult<File>).value);
|
if (result.status === 'rejected') {
|
||||||
|
logger.warn({ error: result.reason }, 'A page failed to convert during PDF processing.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (imageFiles.length === 0 && pageCount > 0) {
|
if (imageFiles.length === 0 && pageCount > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -69,4 +69,9 @@ describe('parsePriceToCents', () => {
|
|||||||
expect(parsePriceToCents(' $10.99 ')).toBe(1099);
|
expect(parsePriceToCents(' $10.99 ')).toBe(1099);
|
||||||
expect(parsePriceToCents(' 99¢ ')).toBe(99);
|
expect(parsePriceToCents(' 99¢ ')).toBe(99);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return null for a price string that matches the pattern but results in NaN (e.g., "$." or ".")', () => {
|
||||||
|
expect(parsePriceToCents('$.')).toBeNull();
|
||||||
|
expect(parsePriceToCents('.')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
85
src/utils/serverUtils.test.ts
Normal file
85
src/utils/serverUtils.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// src/utils/serverUtils.test.ts
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import { getBaseUrl } from './serverUtils';
|
||||||
|
|
||||||
|
// Create a mock logger to spy on its methods
|
||||||
|
const createMockLogger = (): Logger =>
|
||||||
|
({
|
||||||
|
warn: vi.fn(),
|
||||||
|
// Add other logger methods if they were used, but only `warn` is relevant here.
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
silent: vi.fn(),
|
||||||
|
child: vi.fn(() => createMockLogger()),
|
||||||
|
level: 'info',
|
||||||
|
}) as unknown as Logger;
|
||||||
|
|
||||||
|
describe('serverUtils', () => {
|
||||||
|
describe('getBaseUrl', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
let mockLogger: Logger;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mocks and environment variables before each test for isolation
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
mockLogger = createMockLogger();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original environment variables after each test
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use FRONTEND_URL if it is a valid URL', () => {
|
||||||
|
process.env.FRONTEND_URL = 'https://valid.example.com';
|
||||||
|
const baseUrl = getBaseUrl(mockLogger);
|
||||||
|
expect(baseUrl).toBe('https://valid.example.com');
|
||||||
|
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim a trailing slash from FRONTEND_URL', () => {
|
||||||
|
process.env.FRONTEND_URL = 'https://valid.example.com/';
|
||||||
|
const baseUrl = getBaseUrl(mockLogger);
|
||||||
|
expect(baseUrl).toBe('https://valid.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use BASE_URL if FRONTEND_URL is not set', () => {
|
||||||
|
delete process.env.FRONTEND_URL;
|
||||||
|
process.env.BASE_URL = 'https://base.example.com';
|
||||||
|
const baseUrl = getBaseUrl(mockLogger);
|
||||||
|
expect(baseUrl).toBe('https://base.example.com');
|
||||||
|
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to localhost with default port 3000 if no URL is provided', () => {
|
||||||
|
delete process.env.FRONTEND_URL;
|
||||||
|
delete process.env.BASE_URL;
|
||||||
|
delete process.env.PORT;
|
||||||
|
const baseUrl = getBaseUrl(mockLogger);
|
||||||
|
expect(baseUrl).toBe('http://localhost:3000');
|
||||||
|
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to localhost with the specified PORT if no URL is provided', () => {
|
||||||
|
delete process.env.FRONTEND_URL;
|
||||||
|
delete process.env.BASE_URL;
|
||||||
|
process.env.PORT = '8888';
|
||||||
|
const baseUrl = getBaseUrl(mockLogger);
|
||||||
|
expect(baseUrl).toBe('http://localhost:8888');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log a warning and fall back if FRONTEND_URL is invalid (does not start with http)', () => {
|
||||||
|
process.env.FRONTEND_URL = 'invalid.url.com';
|
||||||
|
const baseUrl = getBaseUrl(mockLogger);
|
||||||
|
expect(baseUrl).toBe('http://localhost:3000');
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: http://localhost:3000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user