Refactor geocoding services and improve logging

- Updated the Nominatim geocoding service to use a class-based structure and accept a logger instance for better logging control.
- Modified tests for the Nominatim service to align with the new structure and improved logging assertions.
- Removed the disabled notification service test file.
- Added a new GeocodingFailedError class to handle geocoding failures more explicitly.
- Enhanced error logging in the queue service to include structured error objects.
- Updated user service to accept a logger instance for better logging in address upsert operations.
- Added request-scoped logger to Express Request interface for type-safe logging in route handlers.
- Improved logging in utility functions for better debugging and error tracking.
- Created a new GoogleGeocodingService class for Google Maps geocoding with structured logging.
- Added tests for the useAiAnalysis hook to ensure proper functionality and error handling.
This commit is contained in:
2025-12-13 17:52:30 -08:00
parent 728f4a5f7e
commit 2affda25dc
62 changed files with 1928 additions and 1329 deletions

15
express.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
// src/types/express.d.ts
import { Logger } from 'pino';
/**
* This file uses declaration merging to add a custom `log` property to the
* global Express Request interface. This makes the request-scoped logger
* available in a type-safe way in all route handlers, as required by ADR-004.
*/
declare global {
namespace Express {
export interface Request {
log: Logger;
}
}
}

142
package-lock.json generated
View File

@@ -64,6 +64,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.15.6",
"@types/pino": "^7.0.4",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/sharp": "^0.31.1",
@@ -3541,6 +3542,13 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"dev": true,
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -5394,6 +5402,16 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/pino": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.4.tgz",
"integrity": "sha512-yKw1UbZOTe7vP1xMQT+oz3FexwgIpBTrM+AC62vWgAkNRULgLTJWfYX+H5/sKPm8VXFbIcXkC3VZPyuaNioZFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"pino": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -6493,6 +6511,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@@ -13003,6 +13031,16 @@
],
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -13490,6 +13528,46 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
"integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==",
"dev": true,
"license": "MIT"
},
"node_modules/pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -13733,6 +13811,23 @@
"node": ">=8"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -13883,6 +13978,13 @@
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"dev": true,
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -14076,6 +14178,16 @@
"node": ">=10"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/recharts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.4.1.tgz",
@@ -14505,6 +14617,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -14840,6 +14962,16 @@
"node": ">=18"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"dev": true,
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -15625,6 +15757,16 @@
"b4a": "^1.6.4"
}
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"dev": true,
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

@@ -77,6 +77,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.15.6",
"@types/pino": "^7.0.4",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/sharp": "^0.31.1",

View File

@@ -1,5 +1,6 @@
// server.ts
import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import timeout from 'connect-timeout';
import cookieParser from 'cookie-parser';
import listEndpoints from 'express-list-endpoints';
@@ -45,7 +46,7 @@ getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN publ
console.table(res.rows);
})
.catch(err => {
logger.error('[SERVER PROCESS] Could not query users table on startup.', { err });
logger.error({ err }, '[SERVER PROCESS] Could not query users table on startup.');
});
logger.info('-----------------------------------------------\n');
@@ -163,7 +164,7 @@ if (process.env.NODE_ENV !== 'test') {
});
// Start the scheduled background jobs
startBackgroundJobs(backgroundJobService, analyticsQueue, weeklyAnalyticsQueue);
startBackgroundJobs(backgroundJobService, analyticsQueue, weeklyAnalyticsQueue, logger);
// --- Graceful Shutdown Handling ---
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View File

@@ -0,0 +1,246 @@
// src/hooks/useAiAnalysis.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAiAnalysis } from './useAiAnalysis';
import { useApi } from './useApi';
import { AnalysisType } from '../types';
import type { Flyer, FlyerItem, MasterGroceryItem } from '../types';
// 1. Mock dependencies
vi.mock('./useApi');
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
const mockedUseApi = vi.mocked(useApi);
// --- Mocks for each useApi instance ---
const mockGetQuickInsights = { execute: vi.fn(), data: null, loading: false, error: null };
const mockGetDeepDive = { execute: vi.fn(), data: null, loading: false, error: null };
const mockSearchWeb = { execute: vi.fn(), data: null, loading: false, error: null };
const mockPlanTrip = { execute: vi.fn(), data: null, loading: false, error: null };
const mockComparePrices = { execute: vi.fn(), data: null, loading: false, error: null };
const mockGenerateImage = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
// 2. Mock data
const mockFlyerItems: FlyerItem[] = [{ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' }];
const mockWatchedItems: MasterGroceryItem[] = [{ master_grocery_item_id: 101, name: 'Bananas', created_at: '' }];
const mockSelectedFlyer: Flyer = { flyer_id: 1, store: { store_id: 1, name: 'SuperMart', created_at: '' } } as Flyer;
describe('useAiAnalysis Hook', () => {
const defaultParams = {
flyerItems: mockFlyerItems,
selectedFlyer: mockSelectedFlyer,
watchedItems: mockWatchedItems,
};
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock implementations for each test
mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights as any)
.mockReturnValueOnce(mockGetDeepDive as any)
.mockReturnValueOnce(mockSearchWeb as any)
.mockReturnValueOnce(mockPlanTrip as any)
.mockReturnValueOnce(mockComparePrices as any)
.mockReturnValueOnce(mockGenerateImage as any);
// Mock Geolocation API
Object.defineProperty(navigator, 'geolocation', {
writable: true,
value: {
getCurrentPosition: vi.fn().mockImplementation((success) =>
success({ coords: { latitude: 50, longitude: 50 } })
),
},
});
});
it('should initialize with correct default states', () => {
const { result } = renderHook(() => useAiAnalysis(defaultParams));
expect(result.current.results).toEqual({});
expect(result.current.sources).toEqual({});
expect(result.current.loadingStates).toEqual({
[AnalysisType.QUICK_INSIGHTS]: false,
[AnalysisType.DEEP_DIVE]: false,
[AnalysisType.WEB_SEARCH]: false,
[AnalysisType.PLAN_TRIP]: false,
[AnalysisType.COMPARE_PRICES]: false,
});
expect(result.current.error).toBeNull();
expect(result.current.generatedImageUrl).toBeNull();
expect(result.current.isGeneratingImage).toBe(false);
});
describe('runAnalysis', () => {
it('should call the correct execute function for QUICK_INSIGHTS', async () => {
mockGetQuickInsights.execute.mockResolvedValue('Quick insights text');
const { result } = renderHook(() => useAiAnalysis(defaultParams));
await act(async () => {
await result.current.runAnalysis(AnalysisType.QUICK_INSIGHTS);
});
expect(mockGetQuickInsights.execute).toHaveBeenCalledWith(mockFlyerItems);
});
it('should update results when quickInsightsData changes', () => {
const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams));
// Simulate useApi returning new data by re-rendering with a new mock value
mockedUseApi.mockReset()
.mockReturnValueOnce({ ...mockGetQuickInsights, data: 'New insights' } as any)
.mockReturnValue(mockGetDeepDive as any); // provide defaults for others
rerender();
expect(result.current.results[AnalysisType.QUICK_INSIGHTS]).toBe('New insights');
});
it('should call the correct execute function for DEEP_DIVE', async () => {
mockGetDeepDive.execute.mockResolvedValue('Deep dive text');
const { result } = renderHook(() => useAiAnalysis(defaultParams));
await act(async () => {
await result.current.runAnalysis(AnalysisType.DEEP_DIVE);
});
expect(mockGetDeepDive.execute).toHaveBeenCalledWith(mockFlyerItems);
});
it('should update results and sources when webSearchData changes', () => {
const mockResponse = { text: 'Web search text', sources: [{ web: { uri: 'http://a.com', title: 'Source A' } }] };
const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams));
mockedUseApi.mockReset()
.mockReturnValue(mockGetQuickInsights as any)
.mockReturnValue(mockGetDeepDive as any)
.mockReturnValueOnce({ ...mockSearchWeb, data: mockResponse } as any);
rerender();
expect(result.current.results[AnalysisType.WEB_SEARCH]).toBe('Web search text');
expect(result.current.sources[AnalysisType.WEB_SEARCH]).toEqual([{ uri: 'http://a.com', title: 'Source A' }]);
});
it('should call the correct execute function for COMPARE_PRICES', async () => {
mockComparePrices.execute.mockResolvedValue({ text: 'Price comparison text', sources: [] });
const { result } = renderHook(() => useAiAnalysis(defaultParams));
await act(async () => {
await result.current.runAnalysis(AnalysisType.COMPARE_PRICES);
});
expect(mockComparePrices.execute).toHaveBeenCalledWith(mockWatchedItems);
});
it('should call the correct execute function for PLAN_TRIP with geolocation', async () => {
mockPlanTrip.execute.mockResolvedValue({ text: 'Trip plan text', sources: [{ uri: 'http://maps.com', title: 'Map' }] });
const { result } = renderHook(() => useAiAnalysis(defaultParams));
await act(async () => {
await result.current.runAnalysis(AnalysisType.PLAN_TRIP);
});
expect(navigator.geolocation.getCurrentPosition).toHaveBeenCalled();
expect(mockPlanTrip.execute).toHaveBeenCalledWith(
mockFlyerItems,
mockSelectedFlyer.store,
{ latitude: 50, longitude: 50 }
);
});
it('should derive a generic error message if an API call fails', () => {
const apiError = new Error('API is down');
// Simulate useApi returning an error
mockedUseApi.mockReset()
.mockReturnValueOnce({ ...mockGetQuickInsights, error: apiError } as any);
const { result } = renderHook(() => useAiAnalysis(defaultParams));
expect(result.current.error).toBe('API is down');
});
it('should log an error for geolocation permission denial', async () => {
const geoError = new GeolocationPositionError();
Object.defineProperty(geoError, 'code', { value: GeolocationPositionError.PERMISSION_DENIED });
vi.mocked(navigator.geolocation.getCurrentPosition).mockImplementation((success, error) => {
if (error) error(geoError);
});
// The execute function will reject, and useApi will set the error state
const rejectionError = new Error("Geolocation permission denied.");
mockPlanTrip.execute.mockRejectedValue(rejectionError);
const { result } = renderHook(() => useAiAnalysis(defaultParams));
await act(async () => {
await result.current.runAnalysis(AnalysisType.PLAN_TRIP);
});
// The test now verifies that the error from the failed execute call is propagated.
// The specific user-friendly message is now part of the component that consumes the hook.
expect(result.current.error).toBe(rejectionError.message);
});
});
describe('generateImage', () => {
it('should not run if there are no DEEP_DIVE results', async () => {
const { result } = renderHook(() => useAiAnalysis(defaultParams));
await act(async () => {
await result.current.generateImage();
});
expect(mockGenerateImage.execute).not.toHaveBeenCalled();
});
it('should call the API and set the image URL on success', async () => {
const { result } = renderHook(() => useAiAnalysis(defaultParams));
// First, simulate having a deep dive result
act(() => {
// This state is internal, so we test the effect by calling the function
result.current.results[AnalysisType.DEEP_DIVE] = 'A great meal plan';
});
mockGenerateImage.execute.mockResolvedValue('base64string');
await act(async () => {
await result.current.generateImage();
});
expect(mockGenerateImage.execute).toHaveBeenCalledWith('A great meal plan');
});
it('should set an error if image generation fails', async () => {
const { result } = renderHook(() => useAiAnalysis(defaultParams));
act(() => {
result.current.results[AnalysisType.DEEP_DIVE] = 'A great meal plan';
});
const apiError = new Error('Image model failed');
mockGenerateImage.execute.mockRejectedValue(apiError);
// Re-mock the useApi for generateImage to return the error
mockedUseApi.mockReset()
.mockReturnValue(mockGetQuickInsights as any).mockReturnValue(mockGetDeepDive as any)
.mockReturnValue(mockSearchWeb as any).mockReturnValue(mockPlanTrip as any)
.mockReturnValue(mockComparePrices as any)
.mockReturnValueOnce({ ...mockGenerateImage, error: apiError } as any);
await act(async () => {
await result.current.generateImage();
});
expect(result.current.error).toBe('Image model failed');
});
});
});

View File

@@ -1,15 +1,21 @@
// src/hooks/useAiAnalysis.ts
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { Flyer, FlyerItem, MasterGroceryItem, AnalysisType } from '../types';
import type { GroundingChunk } from '@google/genai';
import * as aiApiClient from '../services/aiApiClient';
import { logger } from '../services/logger.client';
import { useApi } from './useApi';
interface Source {
uri: string;
title: string;
}
interface GroundedResponse {
text: string;
sources: (GroundingChunk | Source)[];
}
interface UseAiAnalysisParams {
flyerItems: FlyerItem[];
selectedFlyer: Flyer | null;
@@ -17,74 +23,101 @@ interface UseAiAnalysisParams {
}
export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems }: UseAiAnalysisParams) => {
// --- State for results ---
const [results, setResults] = useState<{ [key in AnalysisType]?: string }>({});
const [sources, setSources] = useState<{ [key in AnalysisType]?: Source[] }>({});
const [loadingStates, setLoadingStates] = useState<{ [key in AnalysisType]?: boolean }>({});
const [error, setError] = useState<string | null>(null);
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
// --- API Hooks for each analysis type ---
const { execute: getQuickInsights, data: quickInsightsData, loading: loadingQuickInsights, error: errorQuickInsights } = useApi<string, [FlyerItem[]]>(aiApiClient.getQuickInsights);
const { execute: getDeepDive, data: deepDiveData, loading: loadingDeepDive, error: errorDeepDive } = useApi<string, [FlyerItem[]]>(aiApiClient.getDeepDiveAnalysis);
const { execute: searchWeb, data: webSearchData, loading: loadingWebSearch, error: errorWebSearch } = useApi<GroundedResponse, [FlyerItem[]]>(aiApiClient.searchWeb);
const { execute: planTrip, data: tripPlanData, loading: loadingTripPlan, error: errorTripPlan } = useApi<GroundedResponse, [FlyerItem[], Flyer['store'], GeolocationCoordinates]>(aiApiClient.planTripWithMaps);
const { execute: comparePrices, data: priceComparisonData, loading: loadingComparePrices, error: errorComparePrices } = useApi<GroundedResponse, [typeof watchedItems]>(aiApiClient.compareWatchedItemPrices);
const { execute: generateImageApi, data: generatedImageData, loading: isGeneratingImage, error: errorGenerateImage } = useApi<string, [string]>(aiApiClient.generateImageFromText);
// --- Derived State (Loading and Error) ---
const loadingStates = useMemo(() => ({
[AnalysisType.QUICK_INSIGHTS]: loadingQuickInsights,
[AnalysisType.DEEP_DIVE]: loadingDeepDive,
[AnalysisType.WEB_SEARCH]: loadingWebSearch,
[AnalysisType.PLAN_TRIP]: loadingTripPlan,
[AnalysisType.COMPARE_PRICES]: loadingComparePrices,
}), [loadingQuickInsights, loadingDeepDive, loadingWebSearch, loadingTripPlan, loadingComparePrices]);
const error = useMemo(() => {
const firstError = errorQuickInsights || errorDeepDive || errorWebSearch || errorTripPlan || errorComparePrices || errorGenerateImage;
return firstError ? firstError.message : null;
}, [errorQuickInsights, errorDeepDive, errorWebSearch, errorTripPlan, errorComparePrices, errorGenerateImage]);
// --- Effects to update state when API data changes ---
useEffect(() => {
if (quickInsightsData) {
setResults(prev => ({ ...prev, [AnalysisType.QUICK_INSIGHTS]: quickInsightsData }));
}
if (deepDiveData) {
setResults(prev => ({ ...prev, [AnalysisType.DEEP_DIVE]: deepDiveData }));
}
if (webSearchData) {
setResults(prev => ({ ...prev, [AnalysisType.WEB_SEARCH]: webSearchData.text }));
const mappedSources = (webSearchData.sources || []).map(s => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source);
setSources(prev => ({ ...prev, [AnalysisType.WEB_SEARCH]: mappedSources }));
}
if (tripPlanData) {
setResults(prev => ({ ...prev, [AnalysisType.PLAN_TRIP]: tripPlanData.text }));
setSources(prev => ({ ...prev, [AnalysisType.PLAN_TRIP]: tripPlanData.sources as Source[] }));
}
if (priceComparisonData) {
setResults(prev => ({ ...prev, [AnalysisType.COMPARE_PRICES]: priceComparisonData.text }));
const mappedSources = (priceComparisonData.sources || []).map(s => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source);
setSources(prev => ({ ...prev, [AnalysisType.COMPARE_PRICES]: mappedSources }));
}
}, [quickInsightsData, deepDiveData, webSearchData, tripPlanData, priceComparisonData]);
const generatedImageUrl = useMemo(() => generatedImageData ? `data:image/png;base64,${generatedImageData}` : null, [generatedImageData]);
const runAnalysis = useCallback(async (type: AnalysisType) => {
setLoadingStates(prev => ({ ...prev, [type]: true }));
setError(null);
setGeneratedImageUrl(null);
try {
let responseText = '';
let newSources: Source[] = [];
if (type === AnalysisType.QUICK_INSIGHTS) {
responseText = await (await aiApiClient.getQuickInsights(flyerItems)).json();
await getQuickInsights(flyerItems);
} else if (type === AnalysisType.DEEP_DIVE) {
responseText = await (await aiApiClient.getDeepDiveAnalysis(flyerItems)).json();
await getDeepDive(flyerItems);
} else if (type === AnalysisType.WEB_SEARCH) {
const { text, sources: apiSources } = await (await aiApiClient.searchWeb(flyerItems)).json();
newSources = (apiSources || []).map((s: GroundingChunk) => ({ uri: s.web?.uri || '', title: s.web?.title || 'Untitled Source' }));
responseText = text;
await searchWeb(flyerItems);
} else if (type === AnalysisType.PLAN_TRIP) {
const userLocation = await new Promise<GeolocationCoordinates>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(pos => resolve(pos.coords), err => reject(err));
});
const { text, sources: tripSources } = await (await aiApiClient.planTripWithMaps(flyerItems, selectedFlyer?.store, userLocation)).json();
responseText = text;
newSources = tripSources;
await planTrip(flyerItems, selectedFlyer?.store, userLocation);
} else if (type === AnalysisType.COMPARE_PRICES) {
const { text, sources: apiSources } = await (await aiApiClient.compareWatchedItemPrices(watchedItems)).json();
newSources = (apiSources || []).map((s: GroundingChunk) => ({ uri: s.web?.uri || '', title: s.web?.title || 'Untitled Source' }));
responseText = text;
await comparePrices(watchedItems);
}
setResults(prev => ({ ...prev, [type]: responseText }));
setSources(prev => ({ ...prev, [type]: newSources }));
} catch (e) {
logger.error(`Analysis failed for type ${type}`, { error: e });
let userFriendlyMessage = `Failed to get ${type.replace(/_/g, ' ')}. Please try again.`;
// The useApi hook now handles setting the error state.
// We can add specific logging here if needed.
logger.error(`runAnalysis caught an error for type ${type}`, { error: e });
if (e instanceof GeolocationPositionError && e.code === GeolocationPositionError.PERMISSION_DENIED) {
userFriendlyMessage = "Please allow location access to use this feature.";
} else if (e instanceof Error) {
userFriendlyMessage = e.message;
// The useApi hook won't catch this, so we can manually set an error.
// However, the current useApi implementation doesn't expose setError.
// For now, we rely on the error thrown by useApi's execute function.
// A future improvement could be to have useApi return its setError function.
}
setError(userFriendlyMessage);
} finally {
setLoadingStates(prev => ({ ...prev, [type]: false }));
}
}, [flyerItems, selectedFlyer?.store, watchedItems]);
}, [
flyerItems,
selectedFlyer?.store,
watchedItems,
getQuickInsights,
getDeepDive,
searchWeb,
planTrip,
comparePrices
]);
const generateImage = useCallback(async () => {
const mealPlanText = results[AnalysisType.DEEP_DIVE];
if (!mealPlanText) return;
setIsGeneratingImage(true);
try {
const base64Image = await (await aiApiClient.generateImageFromText(mealPlanText)).json();
setGeneratedImageUrl(`data:image/png;base64,${base64Image}`);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred.';
setError(`Failed to generate image: ${errorMessage}`);
} finally {
setIsGeneratingImage(false);
}
}, [results]);
await generateImageApi(mealPlanText);
}, [results, generateImageApi]);
return {
results,

View File

@@ -89,7 +89,8 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
} else {
// For 4xx errors, log at a lower level (e.g., 'warn') to avoid flooding error trackers.
logger.warn(`Client Error: ${statusCode} on ${req.method} ${req.path}`, {
errorMessage: message,
// Including the specific message can be helpful for debugging client errors.
errorMessage: err.message,
user: getLoggableUser(req),
path: req.path,
method: req.method,

View File

@@ -1,5 +1,6 @@
// src/middleware/fileUpload.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ValidationError } from '../services/db/errors.db';
/**
* Middleware to check if a file was uploaded by multer.
@@ -7,7 +8,13 @@ import { Request, Response, NextFunction } from 'express';
*/
export const requireFileUpload = (fieldName: string) => (req: Request, res: Response, next: NextFunction) => {
if (!req.file || req.file.fieldname !== fieldName) {
return res.status(400).json({ message: `A file for the '${fieldName}' field is required.` });
// Use ValidationError for consistency with ADR-003.
// The errorHandler will format this into a 400 response with a structured error.
const validationIssue = {
path: ['file', fieldName],
message: `A file for the '${fieldName}' field is required.`,
};
return next(new ValidationError([validationIssue]));
}
next();
};

View File

@@ -101,7 +101,7 @@ try {
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
} catch (err) {
logger.error(`Failed to create storage path (${storagePath}). File uploads may fail.`, { error: err });
logger.error({ error: err }, `Failed to create storage path (${storagePath}). File uploads may fail.`);
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
@@ -127,9 +127,9 @@ router.use((req: Request, res: Response, next: NextFunction) => {
const contentType = req.headers['content-type'] || '';
const contentLength = req.headers['content-length'] || 'unknown';
const authPresent = !!req.headers['authorization'];
logger.debug('[API /ai] Incoming request', { method: req.method, url: req.originalUrl, contentType, contentLength, authPresent });
logger.debug({ method: req.method, url: req.originalUrl, contentType, contentLength, authPresent }, '[API /ai] Incoming request');
} catch (e) {
logger.error('Failed to log incoming AI request headers', { error: e });
logger.error({ error: e }, 'Failed to log incoming AI request headers');
}
next();
});
@@ -226,8 +226,8 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
}
// Diagnostic & tolerant parsing for flyers/process
logger.debug('[API /ai/flyers/process] req.body keys:', Object.keys(req.body || {}));
logger.debug('[API /ai/flyers/process] file present:', !!req.file);
logger.debug({ keys: Object.keys(req.body || {}) }, '[API /ai/flyers/process] req.body keys:');
logger.debug({ filePresent: !!req.file }, '[API /ai/flyers/process] file present:');
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: FlyerProcessPayload = {};
@@ -236,11 +236,11 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
const raw = (req.body.data ?? req.body.extractedData);
logger.debug('[API /ai/flyers/process] raw extractedData type:', typeof raw, 'length:', raw && raw.length ? raw.length : 0);
logger.debug({ type: typeof raw, length: raw?.length ?? 0 }, '[API /ai/flyers/process] raw extractedData');
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign', { error: errMsg(err) });
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign');
parsed = (typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
@@ -250,7 +250,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
try {
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object', { error: errMsg(err) });
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object');
parsed = req.body || {};
}
// extractedData might be nested under `data` or `extractedData`, or the body itself may be the extracted data.
@@ -259,7 +259,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
const inner = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
extractedData = inner.extractedData ?? inner;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to parse parsed.data; falling back', { error: errMsg(err) });
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to parse parsed.data; falling back');
extractedData = parsed.data as Partial<ExtractedCoreData>;
}
} else if (parsed.extractedData) {
@@ -274,7 +274,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
}
}
} catch (err) {
logger.error('[API /ai/flyers/process] Unexpected error while parsing request body', { error: err });
logger.error({ error: err }, '[API /ai/flyers/process] Unexpected error while parsing request body');
parsed = {};
extractedData = {};
}
@@ -286,7 +286,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
// Validate extractedData to avoid database errors (e.g., null store_name)
if (!extractedData || typeof extractedData !== 'object') {
logger.warn('Missing extractedData in /api/ai/flyers/process payload.', { bodyData: parsed });
logger.warn({ bodyData: parsed }, 'Missing extractedData in /api/ai/flyers/process payload.');
// Don't fail hard here; proceed with empty items and fallback store name so the upload can be saved for manual review.
extractedData = {};
}
@@ -314,7 +314,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
// Generate a 64x64 icon from the uploaded flyer image.
const iconsDir = path.join(path.dirname(req.file.path), 'icons');
const iconFileName = await generateFlyerIcon(req.file.path, iconsDir);
const iconFileName = await generateFlyerIcon(req.file.path, iconsDir, req.log);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
// 2. Prepare flyer data for insertion
@@ -435,7 +435,7 @@ router.post('/plan-trip', passport.authenticate('jwt', { session: false }), vali
const result = await aiService.aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result);
} catch (error) {
logger.error('Error in /api/ai/plan-trip endpoint:', { error });
logger.error({ error }, 'Error in /api/ai/plan-trip endpoint:');
next(error);
}
});

View File

@@ -177,7 +177,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('getQuickInsights', () => {
it('should send items as JSON in the body', async () => {
const items = [{ item: 'apple' }];
await aiApiClient.getQuickInsights(items, 'test-token');
await aiApiClient.getQuickInsights(items, undefined, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
@@ -190,7 +190,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('getDeepDiveAnalysis', () => {
it('should send items as JSON in the body', async () => {
const items = [{ item: 'apple' }];
await aiApiClient.getDeepDiveAnalysis(items, 'test-token');
await aiApiClient.getDeepDiveAnalysis(items, undefined, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
@@ -203,7 +203,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('searchWeb', () => {
it('should send items as JSON in the body', async () => {
const items = [{ item: 'search me' }];
await aiApiClient.searchWeb(items, 'test-token');
await aiApiClient.searchWeb(items, undefined, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
@@ -216,7 +216,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('generateImageFromText', () => {
it('should send prompt as JSON in the body', async () => {
const prompt = 'A tasty burger';
await aiApiClient.generateImageFromText(prompt, 'test-token');
await aiApiClient.generateImageFromText(prompt, undefined, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
@@ -229,7 +229,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('generateSpeechFromText', () => {
it('should send text as JSON in the body', async () => {
const text = 'Hello world';
await aiApiClient.generateSpeechFromText(text, 'test-token');
await aiApiClient.generateSpeechFromText(text, undefined, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];

View File

@@ -74,27 +74,30 @@ export const extractLogoFromImage = async (imageFiles: File[], tokenOverride?: s
}, tokenOverride);
};
export const getQuickInsights = async (items: Partial<FlyerItem>[], tokenOverride?: string): Promise<Response> => {
export const getQuickInsights = async (items: Partial<FlyerItem>[], signal?: AbortSignal, tokenOverride?: string): Promise<Response> => {
return apiFetch('/ai/quick-insights', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
}, tokenOverride);
};
export const getDeepDiveAnalysis = async (items: Partial<FlyerItem>[], tokenOverride?: string): Promise<Response> => {
export const getDeepDiveAnalysis = async (items: Partial<FlyerItem>[], signal?: AbortSignal, tokenOverride?: string): Promise<Response> => {
return apiFetch('/ai/deep-dive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
}, tokenOverride);
};
export const searchWeb = async (items: Partial<FlyerItem>[], tokenOverride?: string): Promise<Response> => {
export const searchWeb = async (items: Partial<FlyerItem>[], signal?: AbortSignal, tokenOverride?: string): Promise<Response> => {
return apiFetch('/ai/search-web', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
}, tokenOverride);
};
@@ -102,20 +105,14 @@ export const searchWeb = async (items: Partial<FlyerItem>[], tokenOverride?: str
// STUBS FOR FUTURE AI FEATURES
// ============================================================================
/**
* [STUB] Uses Google Maps grounding to find nearby stores and plan a shopping trip.
* @param items The items from the flyer.
* @param store The store associated with the flyer.
* @param userLocation The user's current geographic coordinates.
* @returns A text response with trip planning advice and a list of map sources.
*/
export const planTripWithMaps = async (items: FlyerItem[], store: Store | undefined, userLocation: GeolocationCoordinates, tokenOverride?: string): Promise<Response> => {
export const planTripWithMaps = async (items: FlyerItem[], store: Store | undefined, userLocation: GeolocationCoordinates, signal?: AbortSignal): Promise<Response> => {
logger.debug("Stub: planTripWithMaps called with location:", { userLocation });
return apiFetch('/ai/plan-trip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, store, userLocation }),
}, tokenOverride);
signal,
});
};
/**
@@ -123,12 +120,13 @@ export const planTripWithMaps = async (items: FlyerItem[], store: Store | undefi
* @param prompt A description of the image to generate (e.g., a meal plan).
* @returns A base64-encoded string of the generated PNG image.
*/
export const generateImageFromText = async (prompt: string, tokenOverride?: string): Promise<Response> => {
export const generateImageFromText = async (prompt: string, signal?: AbortSignal, tokenOverride?: string): Promise<Response> => {
logger.debug("Stub: generateImageFromText called with prompt:", { prompt });
return apiFetch('/ai/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal,
}, tokenOverride);
};
@@ -137,12 +135,13 @@ export const generateImageFromText = async (prompt: string, tokenOverride?: stri
* @param text The text to be spoken.
* @returns A base64-encoded string of the raw audio data.
*/
export const generateSpeechFromText = async (text: string, tokenOverride?: string): Promise<Response> => {
export const generateSpeechFromText = async (text: string, signal?: AbortSignal, tokenOverride?: string): Promise<Response> => {
logger.debug("Stub: generateSpeechFromText called with text:", { text });
return apiFetch('/ai/generate-speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
signal,
}, tokenOverride);
};
@@ -202,15 +201,13 @@ export const rescanImageArea = async (
* @param watchedItems An array of the user's watched master grocery items.
* @returns A promise that resolves to the raw `Response` object from the API.
*/
export const compareWatchedItemPrices = async (watchedItems: MasterGroceryItem[]): Promise<Response> => {
const token = localStorage.getItem('authToken');
const response = await fetch('/api/ai/compare-prices', {
export const compareWatchedItemPrices = async (watchedItems: MasterGroceryItem[], signal?: AbortSignal): Promise<Response> => {
// Use the apiFetch wrapper for consistency with other API calls in this file.
// This centralizes token handling and base URL logic.
return apiFetch('/ai/compare-prices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: watchedItems }),
signal,
});
return response;
};

View File

@@ -1,5 +1,6 @@
// src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger as mockLoggerInstance } from './logger.server';
import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService } from './aiService.server';
@@ -20,9 +21,9 @@ describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service
const mockAiClient = { generateContent: vi.fn() };
const mockFileSystem = { readFile: vi.fn() };
// Instantiate the service with our mock dependencies
const aiServiceInstance = new AIService(mockAiClient, mockFileSystem);
const aiServiceInstance = new AIService(mockLoggerInstance, mockAiClient, mockFileSystem);
beforeEach(() => {
vi.clearAllMocks();
@@ -55,7 +56,7 @@ describe('AI Service (Server)', () => {
// Dynamically import the class to re-evaluate the constructor logic
const { AIService } = await import('./aiService.server');
expect(() => new AIService()).toThrow('GEMINI_API_KEY environment variable not set for server-side AI calls.');
expect(() => new AIService(mockLoggerInstance)).toThrow('GEMINI_API_KEY environment variable not set for server-side AI calls.');
});
});
@@ -69,7 +70,7 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockResolvedValue({ text: mockAiResponseText, candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg');
const result = await aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance);
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result).toEqual([
@@ -82,7 +83,7 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockResolvedValue({ text: 'This is not JSON.', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg')).rejects.toThrow(
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance)).rejects.toThrow(
'AI response did not contain a valid JSON array.'
);
});
@@ -92,8 +93,11 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockRejectedValue(apiError);
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg'))
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance))
.rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractItemsFromReceiptImage"
);
});
});
@@ -113,7 +117,7 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse), candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await aiServiceInstance.extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems);
const result = await aiServiceInstance.extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems, undefined, undefined, mockLoggerInstance);
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result.store_name).toBe('Test Store');
@@ -127,7 +131,7 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockResolvedValue({ text: 'not a json object', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems)).rejects.toThrow(
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(
'AI response did not contain a valid JSON object.'
);
});
@@ -138,7 +142,7 @@ describe('AI Service (Server)', () => {
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems))
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance))
.rejects.toThrow('AI response did not contain a valid JSON object.');
});
@@ -149,7 +153,10 @@ describe('AI Service (Server)', () => {
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems)).rejects.toThrow(apiError);
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage"
);
});
});
@@ -201,7 +208,7 @@ describe('AI Service (Server)', () => {
const { logger } = await import('./logger.server');
const responseText = '```json\n{ "key": "value"'; // Missing closing brace
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText)).toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to parse JSON'), expect.any(Object));
expect(logger.error).toHaveBeenCalledWith({ jsonString: responseText, err: expect.any(SyntaxError) }, 'Failed to parse JSON from AI response slice');
});
});
@@ -228,7 +235,7 @@ describe('AI Service (Server)', () => {
// Mock AI response
mockAiClient.generateContent.mockResolvedValue({ text: 'Super Store', candidates: [] });
const result = await aiServiceInstance.extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType);
const result = await aiServiceInstance.extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType, mockLoggerInstance);
expect(mockSharp).toHaveBeenCalledWith(imagePath);
expect(mockExtract).toHaveBeenCalledWith({
@@ -258,8 +265,11 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockRejectedValue(apiError);
mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data'));
await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates'))
await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates', mockLoggerInstance))
.rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractTextFromImageArea for type dates"
);
});
});
@@ -269,7 +279,7 @@ describe('AI Service (Server)', () => {
it('should throw a "feature disabled" error', async () => {
// This test verifies the current implementation which has the feature disabled.
await expect(aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation as any))
await expect(aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation as any, mockLoggerInstance))
.rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
});
});

View File

@@ -7,7 +7,7 @@
import { GoogleGenAI } from '@google/genai';
import fsPromises from 'node:fs/promises';
import { logger } from './logger.server';
import type { Logger } from 'pino';
import { pRateLimit } from 'p-ratelimit';
import type { FlyerItem, MasterGroceryItem, ExtractedFlyerItem } from '../types';
@@ -45,8 +45,10 @@ export class AIService {
private aiClient: IAiClient;
private fs: IFileSystem;
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
private logger: Logger;
constructor(aiClient?: IAiClient, fs?: IFileSystem) {
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger;
if (aiClient) {
this.aiClient = aiClient;
} else {
@@ -76,7 +78,7 @@ export class AIService {
rate: requestsPerMinute,
concurrency: requestsPerMinute, // Allow up to `rate` requests to be running in parallel.
});
logger.info(`[AIService] Rate limiter initialized to ${requestsPerMinute} requests per minute.`);
this.logger.info(`[AIService] Rate limiter initialized to ${requestsPerMinute} requests per minute.`);
}
private async serverFileToGenerativePart(path: string, mimeType: string) {
@@ -158,14 +160,15 @@ export class AIService {
try {
return JSON.parse(jsonString) as T;
} catch (e) {
logger.error("Failed to parse JSON from AI response slice", { jsonString, error: e });
this.logger.error({ jsonString, error: e }, "Failed to parse JSON from AI response slice");
return null;
}
}
async extractItemsFromReceiptImage(
imagePath: string,
imageMimeType: string
imageMimeType: string,
logger: Logger = this.logger
): Promise<{ raw_item_description: string; price_paid_cents: number }[]> {
const prompt = `
Analyze the provided receipt image. Extract all purchased line items.
@@ -200,7 +203,7 @@ export class AIService {
}
return parsedJson;
} catch (apiError) {
logger.error("Google GenAI API call failed in extractItemsFromReceiptImage:", { error: apiError });
this.logger.error({ err: apiError }, "Google GenAI API call failed in extractItemsFromReceiptImage");
throw apiError;
}
}
@@ -210,7 +213,7 @@ export class AIService {
masterItems: MasterGroceryItem[],
submitterIp?: string,
userProfileAddress?: string
): Promise<{
, logger: Logger = this.logger): Promise<{
store_name: string;
valid_from: string | null;
valid_to: string | null;
@@ -248,7 +251,7 @@ export class AIService {
const extractedData = this._parseJsonFromAiResponse<any>(text);
if (!extractedData) {
logger.error("AI response for flyer processing did not contain a valid JSON object.", { responseText: text });
logger.error({ responseText: text }, "AI response for flyer processing did not contain a valid JSON object.");
throw new Error('AI response did not contain a valid JSON object.');
}
@@ -258,7 +261,7 @@ export class AIService {
}
return extractedData;
} catch (apiError) {
logger.error("Google GenAI API call failed in extractCoreDataFromFlyerImage:", { error: apiError });
logger.error({ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage");
throw apiError;
}
}
@@ -291,7 +294,7 @@ export class AIService {
imageMimeType: string,
cropArea: { x: number; y: number; width: number; height: number },
extractionType: 'store_name' | 'dates' | 'item_details'
): Promise<{ text: string }> {
, logger: Logger = this.logger): Promise<{ text: string }> {
// 1. Define prompts based on the extraction type
const prompts = {
store_name: 'What is the store name in this image? Respond with only the name.',
@@ -334,7 +337,7 @@ export class AIService {
logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`);
return { text };
} catch (apiError) {
logger.error(`Google GenAI API call failed in extractTextFromImageArea for type ${extractionType}:`, { error: apiError });
logger.error({ err: apiError }, `Google GenAI API call failed in extractTextFromImageArea for type ${extractionType}`);
throw apiError;
}
}
@@ -347,7 +350,7 @@ export class AIService {
* @param userLocation The user's current geographic coordinates.
* @returns A text response with trip planning advice and a list of map sources.
*/
async planTripWithMaps(items: FlyerItem[], store: { name: string } | undefined, userLocation: GeolocationCoordinates): Promise<{text: string; sources: { uri: string; title: string; }[]}> {
async planTripWithMaps(items: FlyerItem[], store: { name: string } | undefined, userLocation: GeolocationCoordinates, logger: Logger = this.logger): Promise<{text: string; sources: { uri: string; title: string; }[]}> {
const topItems = items.slice(0, 5).map(i => i.item).join(', ');
const storeName = store?.name || 'the grocery store';
@@ -368,7 +371,7 @@ export class AIService {
}));
return { text: response.text ?? '', sources };
} catch (apiError) {
logger.error("Google GenAI API call failed in planTripWithMaps:", { error: apiError });
logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps");
throw apiError;
}
@@ -378,4 +381,5 @@ export class AIService {
}
// Export a singleton instance of the service for use throughout the application.
export const aiService = new AIService();
import { logger } from './logger.server';
export const aiService = new AIService(logger);

View File

@@ -96,7 +96,7 @@ export const apiFetch = async (url: string, options: RequestInit = {}, tokenOver
headers.set('Content-Type', 'application/json');
}
const newOptions = { ...options, headers };
const newOptions = { ...options, headers, signal: options.signal };
let response = await fetch(fullUrl, newOptions);

View File

@@ -1,5 +1,6 @@
// src/services/backgroundJobService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Logger } from 'pino';
// Use vi.hoisted to ensure the mock variable is available when vi.mock is executed.
const { mockCronSchedule } = vi.hoisted(() => {
@@ -14,6 +15,9 @@ vi.mock('../services/logger.server', () => ({
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
},
}));
@@ -56,16 +60,18 @@ describe('Background Job Service', () => {
{ user_id: 'user-2', email: 'user2@test.com', full_name: 'User Two', master_item_id: 3, item_name: 'Bread', best_price_in_cents: 250, store_name: 'Bakery', flyer_id: 103, valid_to: '2024-10-22' },
];
// This mockLogger is for the service instance, not the global one used by cron.schedule
const mockServiceLogger = {
// Helper to create a type-safe mock logger
const createMockLogger = (): Logger => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
child: vi.fn(() => createMockLogger()),
} as unknown as Logger);
// Instantiate the service with mock dependencies for each test run
const service = new BackgroundJobService(mockPersonalizationRepo as any, mockNotificationRepo as any, mockEmailQueue as unknown as Queue<any>, mockServiceLogger);
const mockServiceLogger = createMockLogger();
const service = new BackgroundJobService(mockPersonalizationRepo as any, mockNotificationRepo as any, mockEmailQueue as unknown as Queue<any>, mockServiceLogger as any);
it('should do nothing if no deals are found for any user', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
@@ -125,8 +131,8 @@ describe('Background Job Service', () => {
// Check that it logged the error for user 1
expect(mockServiceLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringContaining('Failed to process deals for user user-1'),
expect.any(Object)
);
// Check that it still processed user 2 successfully
@@ -141,8 +147,8 @@ describe('Background Job Service', () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockRejectedValue(new Error('Critical DB Failure'));
await expect(service.runDailyDealCheck()).rejects.toThrow('Critical DB Failure');
expect(mockServiceLogger.error).toHaveBeenCalledWith(
'[BackgroundJob] A critical error occurred during the daily deal check:',
expect.any(Object)
{ error: expect.any(Error) },
'[BackgroundJob] A critical error occurred during the daily deal check:'
);
});
@@ -155,7 +161,7 @@ describe('Background Job Service', () => {
// Act & Assert
await expect(service.runDailyDealCheck()).rejects.toThrow(dbError);
expect(mockServiceLogger.error).toHaveBeenCalledWith(
'[BackgroundJob] A critical error occurred during the daily deal check:', { error: dbError }
{ err: dbError }, '[BackgroundJob] A critical error occurred during the daily deal check'
);
});
});
@@ -181,7 +187,7 @@ describe('Background Job Service', () => {
});
it('should schedule three cron jobs with the correct schedules', () => {
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
expect(mockCronSchedule).toHaveBeenCalledTimes(3);
expect(mockCronSchedule).toHaveBeenCalledWith('0 2 * * *', expect.any(Function));
@@ -190,7 +196,7 @@ describe('Background Job Service', () => {
});
it('should call runDailyDealCheck when the first cron job function is executed', async () => {
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
// Get the callback function for the first cron job
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
@@ -202,15 +208,15 @@ describe('Background Job Service', () => {
it('should log an error and release the lock if runDailyDealCheck fails', async () => {
const jobError = new Error('Cron job failed');
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockRejectedValue(jobError);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
await dailyDealCheckCallback();
expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
expect(globalMockLogger.error).toHaveBeenCalledWith(
'[BackgroundJob] Cron job for daily deal check failed unexpectedly.',
{ error: jobError }
{ err: jobError },
'[BackgroundJob] Cron job for daily deal check failed unexpectedly.'
);
// It should run again, proving the lock was released in the finally block
await dailyDealCheckCallback();
@@ -224,7 +230,7 @@ describe('Background Job Service', () => {
// Make the first call hang indefinitely
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {}));
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
// Trigger the job once, it will hang
@@ -239,7 +245,7 @@ describe('Background Job Service', () => {
});
it('should enqueue an analytics job when the second cron job function is executed', async () => {
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
@@ -250,17 +256,17 @@ describe('Background Job Service', () => {
it('should log an error if enqueuing the analytics job fails', async () => {
const queueError = new Error('Redis is down');
vi.mocked(mockAnalyticsQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
expect(mockAnalyticsQueue.add).toHaveBeenCalledTimes(1);
expect(globalMockLogger.error).toHaveBeenCalledWith('[BackgroundJob] Failed to enqueue daily analytics job.', { error: queueError });
expect(globalMockLogger.error).toHaveBeenCalledWith({ err: queueError }, '[BackgroundJob] Failed to enqueue daily analytics job.');
});
it('should enqueue a weekly analytics job when the third cron job function is executed', async () => {
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
// The weekly job is the third one scheduled
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
@@ -276,12 +282,12 @@ describe('Background Job Service', () => {
it('should log an error if enqueuing the weekly analytics job fails', async () => {
const queueError = new Error('Redis is down for weekly job');
vi.mocked(mockWeeklyAnalyticsQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue);
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger as any);
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith('[BackgroundJob] Failed to enqueue weekly analytics job.', { error: queueError });
expect(globalMockLogger.error).toHaveBeenCalledWith({ err: queueError }, '[BackgroundJob] Failed to enqueue weekly analytics job.');
});
});
});

View File

@@ -1,6 +1,6 @@
// src/services/backgroundJobService.ts
import cron from 'node-cron';
import { logger } from './logger.server';
import type { Logger } from 'pino';
import type { Queue } from 'bullmq';
import { Notification, WatchedItemDeal } from '../types';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
@@ -20,7 +20,7 @@ export class BackgroundJobService {
private personalizationRepo: PersonalizationRepository,
private notificationRepo: NotificationRepository, // Use the imported type here
private emailQueue: Queue<EmailJobData>,
private logger: typeof import('./logger.server').logger
private logger: Logger
) {}
/**
@@ -69,7 +69,7 @@ export class BackgroundJobService {
try {
// 1. Get all deals for all users in a single, efficient query.
const allDeals = await this.personalizationRepo.getBestSalePricesForAllUsers();
const allDeals = await this.personalizationRepo.getBestSalePricesForAllUsers(this.logger);
if (allDeals.length === 0) {
this.logger.info('[BackgroundJob] No deals found for any watched items. Skipping.');
@@ -107,7 +107,7 @@ export class BackgroundJobService {
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(`[BackgroundJob] Failed to process deals for user ${user.user_id}:`, { error: userError });
this.logger.error({ err: userError }, `[BackgroundJob] Failed to process deals for user ${user.user_id}`);
return null; // Return null on error for this user.
}
});
@@ -124,13 +124,13 @@ export class BackgroundJobService {
// 7. Bulk insert all in-app notifications in a single query.
if (allNotifications.length > 0) {
await this.notificationRepo.createBulkNotifications(allNotifications);
await this.notificationRepo.createBulkNotifications(allNotifications, this.logger);
this.logger.info(`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`);
}
this.logger.info('[BackgroundJob] Daily deal check completed successfully.');
} catch (error) {
this.logger.error('[BackgroundJob] A critical error occurred during the daily deal check:', { error });
this.logger.error({ err: error }, '[BackgroundJob] A critical error occurred during the daily deal check');
// Re-throw the error so the cron wrapper knows it failed.
throw error;
}
@@ -149,7 +149,8 @@ let isDailyDealCheckRunning = false;
export function startBackgroundJobs(
backgroundJobService: BackgroundJobService,
analyticsQueue: Queue,
weeklyAnalyticsQueue: Queue // Add this new parameter
weeklyAnalyticsQueue: Queue, // Add this new parameter
logger: Logger
): void {
try {
// Schedule the deal check job to run once every day at 2:00 AM server time.
@@ -165,13 +166,13 @@ export function startBackgroundJobs(
await backgroundJobService.runDailyDealCheck();
} catch (error) {
// The method itself logs details, this is a final catch-all.
logger.error('[BackgroundJob] Cron job for daily deal check failed unexpectedly.', { error });
logger.error({ err: error }, '[BackgroundJob] Cron job for daily deal check failed unexpectedly.');
} finally {
isDailyDealCheckRunning = false;
}
})().catch(error => {
})().catch((error: unknown) => {
// This catch is for unhandled promise rejections from the async wrapper itself.
logger.error('[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.', { error });
logger.error({ error }, '[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.');
isDailyDealCheckRunning = false;
});
});
@@ -188,10 +189,10 @@ export function startBackgroundJobs(
jobId: `daily-report-${reportDate}`
});
} catch (error) {
logger.error('[BackgroundJob] Failed to enqueue daily analytics job.', { error });
logger.error({ err: error }, '[BackgroundJob] Failed to enqueue daily analytics job.');
}
})().catch(error => {
logger.error('[BackgroundJob] Unhandled rejection in analytics report cron wrapper.', { error });
})().catch((error: unknown) => {
logger.error({ err: error }, '[BackgroundJob] Unhandled rejection in analytics report cron wrapper.');
});
});
logger.info('[BackgroundJob] Cron job for daily analytics reports has been scheduled.');
@@ -207,20 +208,21 @@ export function startBackgroundJobs(
jobId: `weekly-report-${reportYear}-${reportWeek}`
});
} catch (error) {
logger.error('[BackgroundJob] Failed to enqueue weekly analytics job.', { error });
logger.error({ err: error }, '[BackgroundJob] Failed to enqueue weekly analytics job.');
}
})().catch(error => {
logger.error('[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.', { error });
})().catch((error: unknown) => {
logger.error({ err: error }, '[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.');
});
});
logger.info('[BackgroundJob] Cron job for weekly analytics reports has been scheduled.');
} catch (error) {
logger.error('[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.', { error });
logger.error({ err: error }, '[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.');
}
}
// Instantiate the service with its real dependencies for use in the application.
import { personalizationRepo, notificationRepo } from './db/index.db';
import { logger } from './logger.server';
import { emailQueue } from './queueService.server';
export const backgroundJobService = new BackgroundJobService(

View File

@@ -12,6 +12,7 @@ vi.unmock('./address.db');
vi.mock('../logger.server', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { logger as mockLogger } from '../logger.server';
describe('Address DB Service', () => {
let addressRepo: AddressRepository;
@@ -26,23 +27,24 @@ describe('Address DB Service', () => {
const mockAddress: Address = { address_id: 1, address_line_1: '123 Main St', city: 'Anytown', province_state: 'CA', postal_code: '12345', country: 'USA' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockAddress] });
const result = await addressRepo.getAddressById(1);
const result = await addressRepo.getAddressById(1, mockLogger);
expect(result).toEqual(mockAddress);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.addresses WHERE address_id = $1', [1]);
});
it('should throw NotFoundError if no address is found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(addressRepo.getAddressById(999)).rejects.toThrow(NotFoundError);
await expect(addressRepo.getAddressById(999)).rejects.toThrow('Address with ID 999 not found.');
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(addressRepo.getAddressById(999, mockLogger)).rejects.toThrow(NotFoundError);
await expect(addressRepo.getAddressById(999, mockLogger)).rejects.toThrow('Address with ID 999 not found.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.getAddressById(1)).rejects.toThrow('Failed to retrieve address.');
await expect(addressRepo.getAddressById(1, mockLogger)).rejects.toThrow('Failed to retrieve address.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, addressId: 1 }, 'Database error in getAddressById');
});
});
@@ -51,7 +53,7 @@ describe('Address DB Service', () => {
const newAddressData = { address_line_1: '456 New Ave', city: 'Newville' };
mockPoolInstance.query.mockResolvedValue({ rows: [{ address_id: 2 }] });
const result = await addressRepo.upsertAddress(newAddressData);
const result = await addressRepo.upsertAddress(newAddressData, mockLogger);
expect(result).toBe(2);
const [query, values] = mockPoolInstance.query.mock.calls[0];
@@ -64,7 +66,7 @@ describe('Address DB Service', () => {
const existingAddressData = { address_id: 1, address_line_1: '789 Old Rd', city: 'Oldtown' };
mockPoolInstance.query.mockResolvedValue({ rows: [{ address_id: 1 }] });
const result = await addressRepo.upsertAddress(existingAddressData);
const result = await addressRepo.upsertAddress(existingAddressData, mockLogger);
expect(result).toBe(1);
const [query, values] = mockPoolInstance.query.mock.calls[0];
@@ -79,7 +81,8 @@ describe('Address DB Service', () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(newAddressData)).rejects.toThrow('Failed to upsert address.');
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow('Failed to upsert address.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, address: newAddressData }, 'Database error in upsertAddress');
});
it('should throw a generic error on UPDATE failure', async () => {
@@ -87,7 +90,8 @@ describe('Address DB Service', () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(existingAddressData)).rejects.toThrow('Failed to upsert address.');
await expect(addressRepo.upsertAddress(existingAddressData, mockLogger)).rejects.toThrow('Failed to upsert address.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, address: existingAddressData }, 'Database error in upsertAddress');
});
it('should throw UniqueConstraintError on duplicate address insert', async () => {
@@ -96,8 +100,9 @@ describe('Address DB Service', () => {
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(newAddressData)).rejects.toThrow(UniqueConstraintError);
await expect(addressRepo.upsertAddress(newAddressData)).rejects.toThrow('An identical address already exists.');
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow(UniqueConstraintError);
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow('An identical address already exists.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, address: newAddressData }, 'Database error in upsertAddress');
});
});
});

View File

@@ -1,7 +1,7 @@
// src/services/db/address.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import { UniqueConstraintError, NotFoundError } from './errors.db';
import { Address } from '../../types';
@@ -17,7 +17,7 @@ export class AddressRepository {
* @param addressId The ID of the address to retrieve.
* @returns A promise that resolves to the Address object or undefined.
*/
async getAddressById(addressId: number): Promise<Address> {
async getAddressById(addressId: number, logger: Logger): Promise<Address> {
try {
const res = await this.db.query<Address>('SELECT * FROM public.addresses WHERE address_id = $1', [addressId]);
if (res.rowCount === 0) {
@@ -28,7 +28,7 @@ export class AddressRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in getAddressById:', { error, addressId });
logger.error({ err: error, addressId }, 'Database error in getAddressById');
throw new Error('Failed to retrieve address.');
}
}
@@ -39,7 +39,7 @@ export class AddressRepository {
* @param address The address data.
* @returns The ID of the created or updated address.
*/
async upsertAddress(address: Partial<Address>): Promise<number> {
async upsertAddress(address: Partial<Address>, logger: Logger): Promise<number> {
try {
const { address_id, ...addressData } = address;
const columns = Object.keys(addressData);
@@ -74,7 +74,7 @@ export class AddressRepository {
return res.rows[0].address_id;
} catch (error) {
logger.error('Database error in upsertAddress:', { error, address });
logger.error({ err: error, address }, 'Database error in upsertAddress');
if (error instanceof Error && 'code' in error && error.code === '23505') throw new UniqueConstraintError('An identical address already exists.');
throw new Error('Failed to upsert address.');
}

View File

@@ -17,6 +17,7 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
@@ -48,7 +49,7 @@ describe('Admin DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections });
const result = await adminRepo.getSuggestedCorrections();
const result = await adminRepo.getSuggestedCorrections(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc"));
expect(result).toEqual(mockCorrections);
@@ -57,14 +58,15 @@ describe('Admin DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.getSuggestedCorrections()).rejects.toThrow('Failed to retrieve suggested corrections.');
await expect(adminRepo.getSuggestedCorrections(mockLogger)).rejects.toThrow('Failed to retrieve suggested corrections.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getSuggestedCorrections');
});
});
describe('approveCorrection', () => {
it('should call the approve_correction database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // Mock the function call
await adminRepo.approveCorrection(123);
await adminRepo.approveCorrection(123, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
});
@@ -72,14 +74,15 @@ describe('Admin DB Service', () => {
it('should throw an error if the database function fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.approveCorrection(123)).rejects.toThrow('Failed to approve correction.');
await expect(adminRepo.approveCorrection(123, mockLogger)).rejects.toThrow('Failed to approve correction.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, correctionId: 123 }, 'Database transaction error in approveCorrection');
});
});
describe('rejectCorrection', () => {
it('should update the correction status to rejected', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1 });
await adminRepo.rejectCorrection(123);
await adminRepo.rejectCorrection(123, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET status = 'rejected'"),
@@ -89,13 +92,14 @@ describe('Admin DB Service', () => {
it('should throw NotFoundError if the correction is not found or not pending', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
await expect(adminRepo.rejectCorrection(123)).rejects.toThrow(NotFoundError);
await expect(adminRepo.rejectCorrection(123)).rejects.toThrow('Correction with ID 123 not found or not in \'pending\' state.');
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow('Correction with ID 123 not found or not in \'pending\' state.');
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(adminRepo.rejectCorrection(123)).rejects.toThrow('Failed to reject correction.');
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow('Failed to reject correction.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), correctionId: 123 }, 'Database error in rejectCorrection');
});
});
@@ -104,7 +108,7 @@ describe('Admin DB Service', () => {
const mockCorrection: SuggestedCorrection = { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '300', status: 'pending', created_at: new Date().toISOString() };
mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
const result = await adminRepo.updateSuggestedCorrection(1, '300');
const result = await adminRepo.updateSuggestedCorrection(1, '300', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET suggested_value = $1"),
@@ -115,13 +119,14 @@ describe('Admin DB Service', () => {
it('should throw an error if the correction is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateSuggestedCorrection(999, 'new value')).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateSuggestedCorrection(999, 'new value')).rejects.toThrow('Correction with ID 999 not found or is not in \'pending\' state.');
await expect(adminRepo.updateSuggestedCorrection(999, 'new value', mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateSuggestedCorrection(999, 'new value', mockLogger)).rejects.toThrow('Correction with ID 999 not found or is not in \'pending\' state.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(adminRepo.updateSuggestedCorrection(1, 'new value')).rejects.toThrow('Failed to update suggested correction.');
await expect(adminRepo.updateSuggestedCorrection(1, 'new value', mockLogger)).rejects.toThrow('Failed to update suggested correction.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), correctionId: 1 }, 'Database error in updateSuggestedCorrection');
});
});
@@ -135,7 +140,7 @@ describe('Admin DB Service', () => {
.mockResolvedValueOnce({ rows: [{ count: '5' }] }) // storeCount
.mockResolvedValueOnce({ rows: [{ count: '2' }] }); // pendingCorrectionCount
const stats = await adminRepo.getApplicationStats();
const stats = await adminRepo.getApplicationStats(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(5);
expect(stats).toEqual({
@@ -154,7 +159,8 @@ describe('Admin DB Service', () => {
.mockRejectedValueOnce(new Error('DB Read Error'));
// The Promise.all should reject, and the function should re-throw the error
await expect(adminRepo.getApplicationStats()).rejects.toThrow('DB Read Error');
await expect(adminRepo.getApplicationStats(mockLogger)).rejects.toThrow('DB Read Error');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in getApplicationStats');
});
});
@@ -163,7 +169,7 @@ describe('Admin DB Service', () => {
const mockStats = [{ date: '2023-01-01', new_users: 5, new_flyers: 2 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockStats });
const result = await adminRepo.getDailyStatsForLast30Days();
const result = await adminRepo.getDailyStatsForLast30Days(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS"));
expect(result).toEqual(mockStats);
@@ -172,7 +178,8 @@ describe('Admin DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.getDailyStatsForLast30Days()).rejects.toThrow('Failed to retrieve daily statistics.');
await expect(adminRepo.getDailyStatsForLast30Days(mockLogger)).rejects.toThrow('Failed to retrieve daily statistics.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getDailyStatsForLast30Days');
});
});
@@ -180,7 +187,7 @@ describe('Admin DB Service', () => {
it('should insert a new activity log entry', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const logData = { userId: 'user-123', action: 'test_action', displayText: 'Test activity' };
await adminRepo.logActivity(logData);
await adminRepo.logActivity(logData, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("INSERT INTO public.activity_log"),
@@ -191,21 +198,23 @@ describe('Admin DB Service', () => {
it('should not throw an error if the database query fails (non-critical)', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const logData = { action: 'test_action', displayText: 'Test activity' };
await expect(adminRepo.logActivity(logData)).resolves.toBeUndefined();
await expect(adminRepo.logActivity(logData, mockLogger)).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), logData }, 'Database error in logActivity');
});
});
describe('getMostFrequentSaleItems', () => {
it('should call the correct database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await adminRepo.getMostFrequentSaleItems(30, 10);
await adminRepo.getMostFrequentSaleItems(30, 10, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.getMostFrequentSaleItems(30, 10)).rejects.toThrow('Failed to get most frequent sale items.');
await expect(adminRepo.getMostFrequentSaleItems(30, 10, mockLogger)).rejects.toThrow('Failed to get most frequent sale items.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getMostFrequentSaleItems');
});
});
@@ -213,34 +222,36 @@ describe('Admin DB Service', () => {
it('should update the comment status and return the updated comment', async () => {
const mockComment = { comment_id: 1, status: 'hidden' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockComment], rowCount: 1 });
const result = await adminRepo.updateRecipeCommentStatus(1, 'hidden');
const result = await adminRepo.updateRecipeCommentStatus(1, 'hidden', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]);
expect(result).toEqual(mockComment);
});
it('should throw an error if the comment is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateRecipeCommentStatus(999, 'hidden')).rejects.toThrow('Recipe comment with ID 999 not found.');
await expect(adminRepo.updateRecipeCommentStatus(999, 'hidden', mockLogger)).rejects.toThrow('Recipe comment with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.updateRecipeCommentStatus(1, 'hidden')).rejects.toThrow('Failed to update recipe comment status.');
await expect(adminRepo.updateRecipeCommentStatus(1, 'hidden', mockLogger)).rejects.toThrow('Failed to update recipe comment status.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, commentId: 1, status: 'hidden' }, 'Database error in updateRecipeCommentStatus');
});
});
describe('getUnmatchedFlyerItems', () => {
it('should execute the correct query to get unmatched items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await adminRepo.getUnmatchedFlyerItems();
await adminRepo.getUnmatchedFlyerItems(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi'));
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.getUnmatchedFlyerItems()).rejects.toThrow('Failed to retrieve unmatched flyer items.');
await expect(adminRepo.getUnmatchedFlyerItems(mockLogger)).rejects.toThrow('Failed to retrieve unmatched flyer items.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getUnmatchedFlyerItems');
});
});
@@ -248,21 +259,22 @@ describe('Admin DB Service', () => {
it('should update the recipe status and return the updated recipe', async () => {
const mockRecipe = { recipe_id: 1, status: 'public' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
const result = await adminRepo.updateRecipeStatus(1, 'public');
const result = await adminRepo.updateRecipeStatus(1, 'public', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]);
expect(result).toEqual(mockRecipe);
});
it('should throw an error if the recipe is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateRecipeStatus(999, 'public')).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateRecipeStatus(999, 'public')).rejects.toThrow('Recipe with ID 999 not found.');
await expect(adminRepo.updateRecipeStatus(999, 'public', mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateRecipeStatus(999, 'public', mockLogger)).rejects.toThrow('Recipe with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.updateRecipeStatus(1, 'public')).rejects.toThrow('Failed to update recipe status.');
await expect(adminRepo.updateRecipeStatus(1, 'public', mockLogger)).rejects.toThrow('Failed to update recipe status.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1, status: 'public' }, 'Database error in updateRecipeStatus');
});
});
@@ -277,7 +289,7 @@ describe('Admin DB Service', () => {
return callback(mockClient as any);
});
await adminRepo.resolveUnmatchedFlyerItem(1, 101);
await adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT flyer_item_id FROM public.unmatched_flyer_items'), [1]);
@@ -293,8 +305,8 @@ describe('Admin DB Service', () => {
throw new NotFoundError(`Unmatched flyer item with ID 999 not found.`); // Re-throw for the outer expect
});
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101)).rejects.toThrow(NotFoundError);
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101)).rejects.toThrow('Unmatched flyer item with ID 999 not found.');
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101, mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101, mockLogger)).rejects.toThrow('Unmatched flyer item with ID 999 not found.');
});
it('should rollback transaction on generic error', async () => {
@@ -308,35 +320,37 @@ describe('Admin DB Service', () => {
throw dbError; // Re-throw for the outer expect
});
await expect(adminRepo.resolveUnmatchedFlyerItem(1, 101)).rejects.toThrow('Failed to resolve unmatched flyer item.');
await expect(adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger)).rejects.toThrow('Failed to resolve unmatched flyer item.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, unmatchedFlyerItemId: 1, masterItemId: 101 }, 'Database transaction error in resolveUnmatchedFlyerItem');
});
});
describe('ignoreUnmatchedFlyerItem', () => {
it('should update the status of an unmatched item to "ignored"', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1 });
await adminRepo.ignoreUnmatchedFlyerItem(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1", [1]);
await adminRepo.ignoreUnmatchedFlyerItem(1, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'", [1]);
});
it('should throw NotFoundError if the unmatched item is not found or not pending', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
await expect(adminRepo.ignoreUnmatchedFlyerItem(999)).rejects.toThrow(NotFoundError);
await expect(adminRepo.ignoreUnmatchedFlyerItem(999)).rejects.toThrow('Unmatched flyer item with ID 999 not found or not in \'pending\' state.');
await expect(adminRepo.ignoreUnmatchedFlyerItem(999, mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.ignoreUnmatchedFlyerItem(999, mockLogger)).rejects.toThrow('Unmatched flyer item with ID 999 not found or not in \'pending\' state.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.ignoreUnmatchedFlyerItem(1)).rejects.toThrow('Failed to ignore unmatched flyer item.');
await expect(adminRepo.ignoreUnmatchedFlyerItem(1, mockLogger)).rejects.toThrow('Failed to ignore unmatched flyer item.');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'ignored'"), [1]);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, unmatchedFlyerItemId: 1 }, 'Database error in ignoreUnmatchedFlyerItem');
});
});
describe('resetFailedLoginAttempts', () => {
it('should execute an UPDATE query to reset failed attempts', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await adminRepo.resetFailedLoginAttempts('user-123', '127.0.0.1');
await adminRepo.resetFailedLoginAttempts('user-123', '127.0.0.1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.users'),
@@ -347,16 +361,16 @@ describe('Admin DB Service', () => {
it('should not throw an error if the database query fails (non-critical)', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.resetFailedLoginAttempts('user-123', '127.0.0.1')).resolves.toBeUndefined();
await expect(adminRepo.resetFailedLoginAttempts('user-123', '127.0.0.1', mockLogger)).resolves.toBeUndefined();
const { logger } = await import('../logger.server');
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('resetFailedLoginAttempts'), expect.any(Object));
expect(logger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in resetFailedLoginAttempts');
});
});
describe('incrementFailedLoginAttempts', () => {
it('should execute an UPDATE query to increment failed attempts', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await adminRepo.incrementFailedLoginAttempts('user-123');
await adminRepo.incrementFailedLoginAttempts('user-123', mockLogger);
// Fix: Use regex to match query with variable whitespace
expect(mockPoolInstance.query).toHaveBeenCalledWith(
@@ -368,28 +382,30 @@ describe('Admin DB Service', () => {
it('should not throw an error if the database query fails (non-critical)', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.incrementFailedLoginAttempts('user-123')).resolves.toBeUndefined();
await expect(adminRepo.incrementFailedLoginAttempts('user-123', mockLogger)).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in incrementFailedLoginAttempts');
});
});
describe('updateBrandLogo', () => {
it('should execute an UPDATE query for the brand logo', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await adminRepo.updateBrandLogo(1, '/logo.png');
await adminRepo.updateBrandLogo(1, '/logo.png', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]);
});
it('should throw NotFoundError if the brand is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
await expect(adminRepo.updateBrandLogo(999, '/logo.png')).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateBrandLogo(999, '/logo.png')).rejects.toThrow('Brand with ID 999 not found.');
await expect(adminRepo.updateBrandLogo(999, '/logo.png', mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateBrandLogo(999, '/logo.png', mockLogger)).rejects.toThrow('Brand with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.updateBrandLogo(1, '/logo.png')).rejects.toThrow('Failed to update brand logo in database.');
await expect(adminRepo.updateBrandLogo(1, '/logo.png', mockLogger)).rejects.toThrow('Failed to update brand logo in database.');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.brands SET logo_url'), ['/logo.png', 1]);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, brandId: 1 }, 'Database error in updateBrandLogo');
});
});
@@ -397,35 +413,37 @@ describe('Admin DB Service', () => {
it('should update the receipt status and return the updated receipt', async () => {
const mockReceipt = { receipt_id: 1, status: 'completed' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 });
const result = await adminRepo.updateReceiptStatus(1, 'completed');
const result = await adminRepo.updateReceiptStatus(1, 'completed', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]);
expect(result).toEqual(mockReceipt);
});
it('should throw an error if the receipt is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateReceiptStatus(999, 'completed')).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateReceiptStatus(999, 'completed')).rejects.toThrow('Receipt with ID 999 not found.');
await expect(adminRepo.updateReceiptStatus(999, 'completed', mockLogger)).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateReceiptStatus(999, 'completed', mockLogger)).rejects.toThrow('Receipt with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.updateReceiptStatus(1, 'completed')).rejects.toThrow('Failed to update receipt status.');
await expect(adminRepo.updateReceiptStatus(1, 'completed', mockLogger)).rejects.toThrow('Failed to update receipt status.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1, status: 'completed' }, 'Database error in updateReceiptStatus');
});
});
describe('getActivityLog', () => {
it('should call the get_activity_log database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await adminRepo.getActivityLog(50, 0);
await adminRepo.getActivityLog(50, 0, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_activity_log($1, $2)', [50, 0]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.getActivityLog(50, 0)).rejects.toThrow('Failed to retrieve activity log.');
await expect(adminRepo.getActivityLog(50, 0, mockLogger)).rejects.toThrow('Failed to retrieve activity log.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, limit: 50, offset: 0 }, 'Database error in getActivityLog');
});
});
@@ -433,7 +451,7 @@ describe('Admin DB Service', () => {
it('should return a list of all users for the admin view', async () => {
const mockUsers: AdminUserView[] = [{ user_id: '1', email: 'test@test.com', created_at: '', role: 'user', full_name: 'Test', avatar_url: null }];
mockPoolInstance.query.mockResolvedValue({ rows: mockUsers });
const result = await adminRepo.getAllUsers();
const result = await adminRepo.getAllUsers(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p'));
expect(result).toEqual(mockUsers);
});
@@ -443,20 +461,21 @@ describe('Admin DB Service', () => {
it('should update the user role and return the updated user', async () => {
const mockUser: User = { user_id: '1', email: 'test@test.com' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1 });
const result = await adminRepo.updateUserRole('1', 'admin');
const result = await adminRepo.updateUserRole('1', 'admin', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', ['admin', '1']);
expect(result).toEqual(mockUser);
});
it('should throw an error if the user is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateUserRole('999', 'admin')).rejects.toThrow('User with ID 999 not found.');
await expect(adminRepo.updateUserRole('999', 'admin', mockLogger)).rejects.toThrow('User with ID 999 not found.');
});
it('should re-throw a generic error if the database query fails for other reasons', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.updateUserRole('1', 'admin')).rejects.toThrow('DB Error');
await expect(adminRepo.updateUserRole('1', 'admin', mockLogger)).rejects.toThrow('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '1', role: 'admin' }, 'Database error in updateUserRole');
});
});
@@ -465,7 +484,8 @@ describe('Admin DB Service', () => {
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.updateUserRole('non-existent-user', 'admin')).rejects.toThrow(ForeignKeyConstraintError);
await expect(adminRepo.updateUserRole('non-existent-user', 'admin')).rejects.toThrow('The specified user does not exist.');
await expect(adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
await expect(adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger)).rejects.toThrow('The specified user does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', role: 'admin' }, 'Database error in updateUserRole');
});
});

View File

@@ -2,7 +2,7 @@
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User, AdminUserView } from '../../types';
export class AdminRepository {
@@ -18,7 +18,7 @@ export class AdminRepository {
* @returns A promise that resolves to an array of SuggestedCorrection objects.
*/
// prettier-ignore
async getSuggestedCorrections(): Promise<SuggestedCorrection[]> {
async getSuggestedCorrections(logger: Logger): Promise<SuggestedCorrection[]> {
try {
const query = `
SELECT
@@ -41,7 +41,7 @@ export class AdminRepository {
const res = await this.db.query<SuggestedCorrection>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getSuggestedCorrections:', { error });
logger.error({ err: error }, 'Database error in getSuggestedCorrections');
throw new Error('Failed to retrieve suggested corrections.');
}
}
@@ -52,7 +52,7 @@ export class AdminRepository {
* @param correctionId The ID of the correction to approve.
*/
// prettier-ignore
async approveCorrection(correctionId: number): Promise<void> {
async approveCorrection(correctionId: number, logger: Logger): Promise<void> {
try {
// The database function `approve_correction` now contains all the logic.
// It finds the correction, applies the change, and updates the status in a single transaction.
@@ -60,7 +60,7 @@ export class AdminRepository {
await this.db.query('SELECT public.approve_correction($1)', [correctionId]);
logger.info(`Successfully approved and applied correction ID: ${correctionId}`);
} catch (error) {
logger.error('Database transaction error in approveCorrection:', { error, correctionId });
logger.error({ err: error, correctionId }, 'Database transaction error in approveCorrection');
throw new Error('Failed to approve correction.');
}
}
@@ -70,7 +70,7 @@ export class AdminRepository {
* @param correctionId The ID of the correction to reject.
*/
// prettier-ignore
async rejectCorrection(correctionId: number): Promise<void> {
async rejectCorrection(correctionId: number, logger: Logger): Promise<void> {
try {
const res = await this.db.query(
"UPDATE public.suggested_corrections SET status = 'rejected' WHERE suggested_correction_id = $1 AND status = 'pending' RETURNING suggested_correction_id",
@@ -82,7 +82,7 @@ export class AdminRepository {
logger.info(`Successfully rejected correction ID: ${correctionId}`);
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in rejectCorrection:', { error, correctionId });
logger.error({ err: error, correctionId }, 'Database error in rejectCorrection');
throw new Error('Failed to reject correction.');
}
}
@@ -94,7 +94,7 @@ export class AdminRepository {
* @returns A promise that resolves to the updated SuggestedCorrection object.
*/
// prettier-ignore
async updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise<SuggestedCorrection> {
async updateSuggestedCorrection(correctionId: number, newSuggestedValue: string, logger: Logger): Promise<SuggestedCorrection> {
try {
const res = await this.db.query<SuggestedCorrection>(
"UPDATE public.suggested_corrections SET suggested_value = $1 WHERE suggested_correction_id = $2 AND status = 'pending' RETURNING *",
@@ -108,7 +108,7 @@ export class AdminRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in updateSuggestedCorrection:', { error, correctionId });
logger.error({ err: error, correctionId }, 'Database error in updateSuggestedCorrection');
throw new Error('Failed to update suggested correction.');
}
}
@@ -118,7 +118,7 @@ export class AdminRepository {
* @returns A promise that resolves to an object containing various application stats.
*/
// prettier-ignore
async getApplicationStats(): Promise<{
async getApplicationStats(logger: Logger): Promise<{
flyerCount: number;
userCount: number;
flyerItemCount: number;
@@ -151,7 +151,7 @@ export class AdminRepository {
pendingCorrectionCount: parseInt(pendingCorrectionCountRes.rows[0].count, 10),
};
} catch (error) {
logger.error('Database error in getApplicationStats:', { error });
logger.error({ err: error }, 'Database error in getApplicationStats');
throw error; // Re-throw the original error to be handled by the caller
}
}
@@ -161,7 +161,7 @@ export class AdminRepository {
* @returns A promise that resolves to an array of daily stats.
*/
// prettier-ignore
async getDailyStatsForLast30Days(): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
async getDailyStatsForLast30Days(logger: Logger): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
try {
const query = `
WITH date_series AS (
@@ -195,7 +195,7 @@ export class AdminRepository {
const res = await this.db.query(query);
return res.rows;
} catch (error) {
logger.error('Database error in getDailyStatsForLast30Days:', { error });
logger.error({ err: error }, 'Database error in getDailyStatsForLast30Days');
throw new Error('Failed to retrieve daily statistics.');
}
}
@@ -206,7 +206,7 @@ export class AdminRepository {
* @param limit The maximum number of items to return.
* @returns A promise that resolves to an array of the most frequent sale items.
*/
async getMostFrequentSaleItems(days: number, limit: number): Promise<MostFrequentSaleItem[]> {
async getMostFrequentSaleItems(days: number, limit: number, logger: Logger): Promise<MostFrequentSaleItem[]> {
// This is a secure parameterized query. The values for `days` and `limit` are passed
// separately from the query string. The database driver safely substitutes the `$1` and `$2`
// placeholders, preventing SQL injection attacks.
@@ -233,7 +233,7 @@ export class AdminRepository {
const res = await this.db.query<MostFrequentSaleItem>(query, [days, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getMostFrequentSaleItems:', { error });
logger.error({ err: error }, 'Database error in getMostFrequentSaleItems');
throw new Error('Failed to get most frequent sale items.');
}
}
@@ -244,7 +244,7 @@ export class AdminRepository {
* @param status The new status ('visible', 'hidden', 'reported').
* @returns A promise that resolves to the updated RecipeComment object.
*/
async updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise<RecipeComment> {
async updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported', logger: Logger): Promise<RecipeComment> {
try {
const res = await this.db.query<RecipeComment>(
'UPDATE public.recipe_comments SET status = $1 WHERE recipe_comment_id = $2 RETURNING *',
@@ -258,7 +258,7 @@ export class AdminRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in updateRecipeCommentStatus:', { error, commentId, status });
logger.error({ err: error, commentId, status }, 'Database error in updateRecipeCommentStatus');
throw new Error('Failed to update recipe comment status.');
}
}
@@ -267,7 +267,7 @@ export class AdminRepository {
* Retrieves all flyer items that could not be automatically matched to a master item.
* @returns A promise that resolves to an array of unmatched flyer items with context.
*/
async getUnmatchedFlyerItems(): Promise<UnmatchedFlyerItem[]> {
async getUnmatchedFlyerItems(logger: Logger): Promise<UnmatchedFlyerItem[]> {
try {
const query = `
SELECT
@@ -289,7 +289,7 @@ export class AdminRepository {
const res = await this.db.query<UnmatchedFlyerItem>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getUnmatchedFlyerItems:', { error });
logger.error({ err: error }, 'Database error in getUnmatchedFlyerItems');
throw new Error('Failed to retrieve unmatched flyer items.');
}
}
@@ -300,7 +300,7 @@ export class AdminRepository {
* @param status The new status ('private', 'pending_review', 'public', 'rejected').
* @returns A promise that resolves to the updated Recipe object.
*/
async updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise<Recipe> {
async updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected', logger: Logger): Promise<Recipe> {
try {
const res = await this.db.query<Recipe>(
'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *',
@@ -312,7 +312,7 @@ export class AdminRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in updateRecipeStatus:', { error, recipeId, status });
logger.error({ err: error, recipeId, status }, 'Database error in updateRecipeStatus');
throw new Error('Failed to update recipe status.'); // Keep generic for other DB errors
}
}
@@ -323,7 +323,7 @@ export class AdminRepository {
* @param unmatchedFlyerItemId The ID from the `unmatched_flyer_items` table.
* @param masterItemId The ID of the `master_grocery_items` to link to.
*/
async resolveUnmatchedFlyerItem(unmatchedFlyerItemId: number, masterItemId: number): Promise<void> {
async resolveUnmatchedFlyerItem(unmatchedFlyerItemId: number, masterItemId: number, logger: Logger): Promise<void> {
try {
await withTransaction(async (client) => {
// First, get the flyer_item_id from the unmatched record
@@ -347,7 +347,7 @@ export class AdminRepository {
logger.info(`Successfully resolved unmatched item ${unmatchedFlyerItemId} to master item ${masterItemId}.`);
});
} catch (error) {
logger.error('Database transaction error in resolveUnmatchedFlyerItem:', { error, unmatchedFlyerItemId, masterItemId });
logger.error({ err: error, unmatchedFlyerItemId, masterItemId }, 'Database transaction error in resolveUnmatchedFlyerItem');
throw new Error('Failed to resolve unmatched flyer item.');
}
}
@@ -356,7 +356,7 @@ export class AdminRepository {
* Ignores an unmatched flyer item by updating its status.
* @param unmatchedFlyerItemId The ID from the `unmatched_flyer_items` table.
*/
async ignoreUnmatchedFlyerItem(unmatchedFlyerItemId: number): Promise<void> {
async ignoreUnmatchedFlyerItem(unmatchedFlyerItemId: number, logger: Logger): Promise<void> {
try {
const res = await this.db.query("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'", [unmatchedFlyerItemId]);
if (res.rowCount === 0) {
@@ -364,7 +364,7 @@ export class AdminRepository {
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in ignoreUnmatchedFlyerItem:', { error, unmatchedFlyerItemId });
logger.error({ err: error, unmatchedFlyerItemId }, 'Database error in ignoreUnmatchedFlyerItem');
throw new Error('Failed to ignore unmatched flyer item.');
}
}
@@ -376,12 +376,12 @@ export class AdminRepository {
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
// prettier-ignore
async getActivityLog(limit: number, offset: number): Promise<ActivityLogItem[]> {
async getActivityLog(limit: number, offset: number, logger: Logger): Promise<ActivityLogItem[]> {
try {
const res = await this.db.query<ActivityLogItem>('SELECT * FROM public.get_activity_log($1, $2)', [limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getActivityLog:', { error, limit, offset });
logger.error({ err: error, limit, offset }, 'Database error in getActivityLog');
throw new Error('Failed to retrieve activity log.');
}
}
@@ -396,8 +396,8 @@ export class AdminRepository {
action: string;
displayText: string;
icon?: string | null;
details?: Record<string, any> | null;
}): Promise<void> {
details?: Record<string, any> | null; // eslint-disable-line @typescript-eslint/no-explicit-any
}, logger: Logger): Promise<void> {
const { userId, action, displayText, icon, details } = logData;
try {
await this.db.query(
@@ -412,7 +412,7 @@ export class AdminRepository {
]
);
} catch (error) {
logger.error('Database error in logActivity:', { error, logData });
logger.error({ err: error, logData }, 'Database error in logActivity');
// We don't re-throw here to prevent logging failures from crashing critical paths.
}
}
@@ -421,7 +421,7 @@ export class AdminRepository {
* Increments the failed login attempt counter for a user.
* @param userId The ID of the user.
*/
async incrementFailedLoginAttempts(userId: string): Promise<void> {
async incrementFailedLoginAttempts(userId: string, logger: Logger): Promise<void> {
try {
await this.db.query(
`UPDATE public.users
@@ -430,7 +430,7 @@ export class AdminRepository {
[userId]
);
} catch (error) {
logger.error('Database error in incrementFailedLoginAttempts:', { error, userId });
logger.error({ err: error, userId }, 'Database error in incrementFailedLoginAttempts');
}
}
@@ -439,7 +439,7 @@ export class AdminRepository {
* @param userId The ID of the user.
* @param ipAddress The IP address from which the successful login occurred.
*/
async resetFailedLoginAttempts(userId: string, ipAddress: string): Promise<void> {
async resetFailedLoginAttempts(userId: string, ipAddress: string, logger: Logger): Promise<void> {
try {
await this.db.query(
`UPDATE public.users
@@ -449,7 +449,7 @@ export class AdminRepository {
);
} catch (error) {
// This is a non-critical operation, so we just log the error and continue.
logger.error('Database error in resetFailedLoginAttempts:', { error, userId });
logger.error({ err: error, userId }, 'Database error in resetFailedLoginAttempts');
}
}
@@ -459,7 +459,7 @@ export class AdminRepository {
* @param logoUrl The new URL for the brand's logo.
*/
// prettier-ignore
async updateBrandLogo(brandId: number, logoUrl: string): Promise<void> {
async updateBrandLogo(brandId: number, logoUrl: string, logger: Logger): Promise<void> {
try {
const res = await this.db.query(
'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2',
@@ -470,7 +470,7 @@ export class AdminRepository {
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in updateBrandLogo:', { error, brandId });
logger.error({ err: error, brandId }, 'Database error in updateBrandLogo');
throw new Error('Failed to update brand logo in database.');
}
}
@@ -481,7 +481,7 @@ export class AdminRepository {
* @param status The new status for the receipt.
* @returns A promise that resolves to the updated Receipt object.
*/
async updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise<Receipt> {
async updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed', logger: Logger): Promise<Receipt> {
try {
const res = await this.db.query<Receipt>(
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE receipt_id = $2 RETURNING *`,
@@ -491,18 +491,23 @@ export class AdminRepository {
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in updateReceiptStatus:', { error, receiptId, status });
logger.error({ err: error, receiptId, status }, 'Database error in updateReceiptStatus');
throw new Error('Failed to update receipt status.');
}
}
async getAllUsers(): Promise<AdminUserView[]> {
const query = `
SELECT u.user_id, u.email, u.created_at, p.role, p.full_name, p.avatar_url
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC;
`;
const res = await this.db.query<AdminUserView>(query);
return res.rows;
async getAllUsers(logger: Logger): Promise<AdminUserView[]> {
try {
const query = `
SELECT u.user_id, u.email, u.created_at, p.role, p.full_name, p.avatar_url
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC;
`;
const res = await this.db.query<AdminUserView>(query);
return res.rows;
} catch (error) {
logger.error({ err: error }, 'Database error in getAllUsers');
throw new Error('Failed to retrieve all users.');
}
};
/**
@@ -511,7 +516,7 @@ export class AdminRepository {
* @param role The new role to assign ('user' or 'admin').
* @returns A promise that resolves to the updated Profile object.
*/
async updateUserRole(userId: string, role: 'user' | 'admin'): Promise<User> {
async updateUserRole(userId: string, role: 'user' | 'admin', logger: Logger): Promise<User> {
try {
const res = await this.db.query<User>(
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
@@ -522,7 +527,7 @@ export class AdminRepository {
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateUserRole:', { error, userId, role });
logger.error({ err: error, userId, role }, 'Database error in updateUserRole');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}

View File

@@ -18,12 +18,18 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
// Mock the gamification repository, as createBudget calls it.
vi.mock('./gamification.db', () => ({
GamificationRepository: class { awardAchievement = vi.fn(); },
}));
import { withTransaction } from './connection.db';
// Mock the gamification repository, as createBudget calls it.
@@ -45,7 +51,7 @@ describe('Budget DB Service', () => {
const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01' }];
mockPoolInstance.query.mockResolvedValue({ rows: mockBudgets });
const result = await budgetRepo.getBudgetsForUser('user-123');
const result = await budgetRepo.getBudgetsForUser('user-123', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC', ['user-123']);
expect(result).toEqual(mockBudgets);
@@ -53,7 +59,7 @@ describe('Budget DB Service', () => {
it('should return an empty array if the user has no budgets', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await budgetRepo.getBudgetsForUser('user-123');
const result = await budgetRepo.getBudgetsForUser('user-123', mockLogger);
expect(result).toEqual([]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), ['user-123']);
});
@@ -61,7 +67,8 @@ describe('Budget DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(budgetRepo.getBudgetsForUser('user-123')).rejects.toThrow('Failed to retrieve budgets.');
await expect(budgetRepo.getBudgetsForUser('user-123', mockLogger)).rejects.toThrow('Failed to retrieve budgets.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getBudgetsForUser');
});
});
@@ -78,11 +85,12 @@ describe('Budget DB Service', () => {
return callback(mockClient as any);
});
const result = await budgetRepo.createBudget('user-123', budgetData);
const result = await budgetRepo.createBudget('user-123', budgetData, mockLogger);
const { GamificationRepository } = await import('./gamification.db');
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.budgets'), expect.any(Array));
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("SELECT public.award_achievement($1, 'First Budget Created')"), ['user-123']);
expect(GamificationRepository.prototype.awardAchievement).toHaveBeenCalledWith('user-123', 'First Budget Created', mockLogger);
expect(result).toEqual(mockCreatedBudget);
expect(withTransaction).toHaveBeenCalledTimes(1);
});
@@ -99,8 +107,8 @@ describe('Budget DB Service', () => {
throw dbError; // Re-throw for the outer expect
});
await expect(budgetRepo.createBudget('non-existent-user', budgetData)).rejects.toThrow(ForeignKeyConstraintError);
await expect(budgetRepo.createBudget('non-existent-user', budgetData)).rejects.toThrow('The specified user does not exist.');
await expect(budgetRepo.createBudget('non-existent-user', budgetData, mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
await expect(budgetRepo.createBudget('non-existent-user', budgetData, mockLogger)).rejects.toThrow('The specified user does not exist.');
});
it('should rollback the transaction if awarding an achievement fails', async () => {
@@ -116,7 +124,8 @@ describe('Budget DB Service', () => {
throw achievementError; // Re-throw for the outer expect
});
await expect(budgetRepo.createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
await expect(budgetRepo.createBudget('user-123', budgetData, mockLogger)).rejects.toThrow('Failed to create budget.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: achievementError, budgetData, userId: 'user-123' }, 'Database error in createBudget');
});
it('should throw a generic error if the database query fails', async () => {
@@ -128,7 +137,8 @@ describe('Budget DB Service', () => {
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError; // Re-throw for the outer expect
});
await expect(budgetRepo.createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
await expect(budgetRepo.createBudget('user-123', budgetData, mockLogger)).rejects.toThrow('Failed to create budget.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, budgetData, userId: 'user-123' }, 'Database error in createBudget');
});
});
@@ -138,7 +148,7 @@ describe('Budget DB Service', () => {
const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Updated Groceries', amount_cents: 55000, period: 'monthly', start_date: '2024-01-01' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUpdatedBudget], rowCount: 1 });
const result = await budgetRepo.updateBudget(1, 'user-123', budgetUpdates);
const result = await budgetRepo.updateBudget(1, 'user-123', budgetUpdates, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.budgets SET'),
@@ -151,20 +161,21 @@ describe('Budget DB Service', () => {
// Arrange: Mock the query to return 0 rows affected
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(budgetRepo.updateBudget(999, 'user-123', { name: 'Fail' })).rejects.toThrow('Budget not found or user does not have permission to update.');
await expect(budgetRepo.updateBudget(999, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Budget not found or user does not have permission to update.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(budgetRepo.updateBudget(1, 'user-123', { name: 'Fail' })).rejects.toThrow('Failed to update budget.');
await expect(budgetRepo.updateBudget(1, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Failed to update budget.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, budgetId: 1, userId: 'user-123' }, 'Database error in updateBudget');
});
});
describe('deleteBudget', () => {
it('should execute a DELETE query with user ownership check', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, command: 'DELETE', rows: [] });
await budgetRepo.deleteBudget(1, 'user-123');
await budgetRepo.deleteBudget(1, 'user-123', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [1, 'user-123']);
});
@@ -172,13 +183,14 @@ describe('Budget DB Service', () => {
// Arrange: Mock the query to return 0 rows affected
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(budgetRepo.deleteBudget(999, 'user-123')).rejects.toThrow('Budget not found or user does not have permission to delete.');
await expect(budgetRepo.deleteBudget(999, 'user-123', mockLogger)).rejects.toThrow('Budget not found or user does not have permission to delete.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(budgetRepo.deleteBudget(1, 'user-123')).rejects.toThrow('Failed to delete budget.');
await expect(budgetRepo.deleteBudget(1, 'user-123', mockLogger)).rejects.toThrow('Failed to delete budget.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, budgetId: 1, userId: 'user-123' }, 'Database error in deleteBudget');
});
});
@@ -187,7 +199,7 @@ describe('Budget DB Service', () => {
const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockSpendingData });
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31');
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_spending_by_category($1, $2, $3)', ['user-123', '2024-01-01', '2024-01-31']);
expect(result).toEqual(mockSpendingData);
@@ -195,14 +207,15 @@ describe('Budget DB Service', () => {
it('should return an empty array if there is no spending data', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31');
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31')).rejects.toThrow('Failed to get spending analysis.');
await expect(budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger)).rejects.toThrow('Failed to get spending analysis.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', startDate: '2024-01-01', endDate: '2024-01-31' }, 'Database error in getSpendingByCategory');
});
});
});

View File

@@ -2,8 +2,9 @@
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import { Budget, SpendingByCategory } from '../../types';
import type { Logger } from 'pino';
import type { Budget, SpendingByCategory } from '../../types';
import { GamificationRepository } from './gamification.db';
export class BudgetRepository {
private db: Pool | PoolClient;
@@ -17,7 +18,7 @@ export class BudgetRepository {
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Budget objects.
*/
async getBudgetsForUser(userId: string): Promise<Budget[]> {
async getBudgetsForUser(userId: string, logger: Logger): Promise<Budget[]> {
try {
const res = await this.db.query<Budget>(
'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
@@ -25,7 +26,7 @@ export class BudgetRepository {
);
return res.rows;
} catch (error) {
logger.error('Database error in getBudgetsForUser:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getBudgetsForUser');
throw new Error('Failed to retrieve budgets.');
}
}
@@ -36,7 +37,7 @@ export class BudgetRepository {
* @param budgetData The data for the new budget.
* @returns A promise that resolves to the newly created Budget object.
*/
async createBudget(userId: string, budgetData: Omit<Budget, 'budget_id' | 'user_id'>): Promise<Budget> {
async createBudget(userId: string, budgetData: Omit<Budget, 'budget_id' | 'user_id'>, logger: Logger): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
try {
return await withTransaction(async (client) => {
@@ -47,7 +48,8 @@ export class BudgetRepository {
// After successfully creating the budget, try to award the 'First Budget Created' achievement.
// The award_achievement function handles checking if the user already has it.
await client.query("SELECT public.award_achievement($1, 'First Budget Created')", [userId]);
const gamificationRepo = new GamificationRepository(client);
await gamificationRepo.awardAchievement(userId, 'First Budget Created', logger);
return res.rows[0];
});
} catch (error) {
@@ -55,7 +57,7 @@ export class BudgetRepository {
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createBudget:', { error });
logger.error({ err: error, budgetData, userId }, 'Database error in createBudget');
throw new Error('Failed to create budget.');
}
}
@@ -67,7 +69,7 @@ export class BudgetRepository {
* @param budgetData The data to update.
* @returns A promise that resolves to the updated Budget object.
*/
async updateBudget(budgetId: number, userId: string, budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id'>>): Promise<Budget> {
async updateBudget(budgetId: number, userId: string, budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id'>>, logger: Logger): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
try {
const res = await this.db.query<Budget>(
@@ -82,7 +84,7 @@ export class BudgetRepository {
if (res.rowCount === 0) throw new NotFoundError('Budget not found or user does not have permission to update.');
return res.rows[0];
} catch (error) {
logger.error('Database error in updateBudget:', { error, budgetId, userId });
logger.error({ err: error, budgetId, userId }, 'Database error in updateBudget');
throw new Error('Failed to update budget.');
}
}
@@ -92,14 +94,14 @@ export class BudgetRepository {
* @param budgetId The ID of the budget to delete.
* @param userId The ID of the user who owns the budget (for verification).
*/
async deleteBudget(budgetId: number, userId: string): Promise<void> {
async deleteBudget(budgetId: number, userId: string, logger: Logger): Promise<void> {
try {
const result = await this.db.query('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [budgetId, userId]);
if (result.rowCount === 0) {
throw new NotFoundError('Budget not found or user does not have permission to delete.');
}
} catch (error) {
logger.error('Database error in deleteBudget:', { error, budgetId, userId });
logger.error({ err: error, budgetId, userId }, 'Database error in deleteBudget');
throw new Error('Failed to delete budget.');
}
}
@@ -111,12 +113,12 @@ export class BudgetRepository {
* @param endDate The end of the date range.
* @returns A promise that resolves to an array of spending data.
*/
async getSpendingByCategory(userId: string, startDate: string, endDate: string): Promise<SpendingByCategory[]> {
async getSpendingByCategory(userId: string, startDate: string, endDate: string, logger: Logger): Promise<SpendingByCategory[]> {
try {
const res = await this.db.query<SpendingByCategory>('SELECT * FROM public.get_spending_by_category($1, $2, $3)', [userId, startDate, endDate]);
return res.rows;
} catch (error) {
logger.error('Database error in getSpendingByCategory:', { error, userId });
logger.error({ err: error, userId, startDate, endDate }, 'Database error in getSpendingByCategory');
throw new Error('Failed to get spending analysis.');
}
}

View File

@@ -62,6 +62,7 @@ vi.mock('../logger.server', () => ({
error: vi.fn(),
},
}));
import { logger } from '../logger.server';
describe('DB Connection Service', () => {
beforeEach(async () => {
@@ -149,6 +150,7 @@ describe('DB Connection Service', () => {
const tableNames = ['users'];
await expect(checkTablesExist(tableNames)).rejects.toThrow('Failed to check for tables in database.');
expect(logger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in checkTablesExist');
});
it('should return an array of missing tables', async () => {
@@ -176,4 +178,45 @@ describe('DB Connection Service', () => {
});
});
});
describe('withTransaction', () => {
it('should commit the transaction on success', async () => {
const { withTransaction } = await import('./connection.db');
const mockClient = {
query: vi.fn().mockResolvedValue({}),
release: vi.fn(),
};
mocks.mockPoolInstance.connect.mockResolvedValue(mockClient);
const callback = vi.fn().mockResolvedValue('success');
const result = await withTransaction(callback);
expect(result).toBe('success');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(callback).toHaveBeenCalledWith(mockClient);
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalledTimes(1);
});
it('should rollback the transaction and log on error', async () => {
const { withTransaction } = await import('./connection.db');
const mockClient = {
query: vi.fn().mockResolvedValue({}),
release: vi.fn(),
};
mocks.mockPoolInstance.connect.mockResolvedValue(mockClient);
const dbError = new Error('Callback failed');
const callback = vi.fn().mockRejectedValue(dbError);
await expect(withTransaction(callback)).rejects.toThrow(dbError);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(callback).toHaveBeenCalledWith(mockClient);
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.query).not.toHaveBeenCalledWith('COMMIT');
expect(logger.error).toHaveBeenCalledWith({ err: dbError }, 'Transaction failed, rolling back.');
expect(mockClient.release).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -39,7 +39,7 @@ export const getPool = (): Pool => {
// Add a listener for connection errors on the pool.
pool.on('error', (err, client) => {
logger.error('Unexpected error on idle client in pool', { error: err });
logger.error({ err, client }, 'Unexpected error on idle client in pool');
// You might want to add logic here to handle the error, e.g., by trying to reconnect.
});
@@ -69,10 +69,10 @@ export async function withTransaction<T>(callback: (client: PoolClient) => Promi
return result;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Transaction failed, rolling back.', {
logger.error({
// Safely access error message
errorMessage: error instanceof Error ? error.message : String(error),
});
err: error,
}, 'Transaction failed, rolling back.');
throw error; // Re-throw the original error to be handled by the caller
} finally {
// Always release the client back to the pool
@@ -103,7 +103,7 @@ export async function checkTablesExist(tableNames: string[]): Promise<string[]>
return missingTables;
} catch (error) {
logger.error('Database error in checkTablesExist:', { error });
logger.error({ err: error }, 'Database error in checkTablesExist');
throw new Error('Failed to check for tables in database.');
}
}

View File

@@ -56,3 +56,12 @@ export class ValidationError extends DatabaseError {
this.validationErrors = errors;
}
}
export class FileUploadError extends Error {
public status = 400;
constructor(message: string) {
super(message);
this.name = 'FileUploadError';
}
}

View File

@@ -14,6 +14,7 @@ import type { FlyerInsert, FlyerItemInsert, Brand, Flyer, FlyerItem, FlyerDbInse
vi.mock('../logger.server', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { logger as mockLogger } from '../logger.server';
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
@@ -40,7 +41,7 @@ describe('Flyer DB Service', () => {
describe('findOrCreateStore', () => {
it('should find an existing store and return its ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
const result = await flyerRepo.findOrCreateStore('Existing Store');
const result = await flyerRepo.findOrCreateStore('Existing Store', mockLogger);
expect(result).toBe(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT store_id FROM public.stores WHERE name = $1', ['Existing Store']);
});
@@ -49,7 +50,7 @@ describe('Flyer DB Service', () => {
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // First SELECT finds nothing
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] }); // INSERT returns new ID
const result = await flyerRepo.findOrCreateStore('New Store');
const result = await flyerRepo.findOrCreateStore('New Store', mockLogger);
expect(result).toBe(2);
expect(mockPoolInstance.query).toHaveBeenCalledWith('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', ['New Store']);
});
@@ -63,7 +64,7 @@ describe('Flyer DB Service', () => {
.mockRejectedValueOnce(uniqueConstraintError) // INSERT fails due to race condition
.mockResolvedValueOnce({ rows: [{ store_id: 3 }] }); // Second SELECT finds the store
const result = await flyerRepo.findOrCreateStore('Racy Store');
const result = await flyerRepo.findOrCreateStore('Racy Store', mockLogger);
expect(result).toBe(3);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(3);
});
@@ -71,7 +72,8 @@ describe('Flyer DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.findOrCreateStore('Any Store')).rejects.toThrow('Failed to find or create store in database.');
await expect(flyerRepo.findOrCreateStore('Any Store', mockLogger)).rejects.toThrow('Failed to find or create store in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, storeName: 'Any Store' }, 'Database error in findOrCreateStore');
});
it('should throw an error if race condition recovery fails', async () => {
@@ -83,7 +85,8 @@ describe('Flyer DB Service', () => {
.mockRejectedValueOnce(uniqueConstraintError) // INSERT fails
.mockRejectedValueOnce(new Error('Second select fails')); // Recovery SELECT fails
await expect(flyerRepo.findOrCreateStore('Racy Store')).rejects.toThrow('Failed to find or create store in database.');
await expect(flyerRepo.findOrCreateStore('Racy Store', mockLogger)).rejects.toThrow('Failed to find or create store in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), storeName: 'Racy Store' }, 'Database error in findOrCreateStore');
});
});
@@ -104,7 +107,7 @@ describe('Flyer DB Service', () => {
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await flyerRepo.insertFlyer(flyerData);
const result = await flyerRepo.insertFlyer(flyerData, mockLogger);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
@@ -131,14 +134,16 @@ describe('Flyer DB Service', () => {
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyer(flyerData)).rejects.toThrow(UniqueConstraintError);
await expect(flyerRepo.insertFlyer(flyerData)).rejects.toThrow('A flyer with this checksum already exists.');
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(UniqueConstraintError);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow('A flyer with this checksum already exists.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerData }, 'Database error in insertFlyer');
});
it('should throw a generic error if the database query fails', async () => {
const flyerData: FlyerDbInsert = { checksum: 'fail-checksum' } as FlyerDbInsert;
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
await expect(flyerRepo.insertFlyer(flyerData)).rejects.toThrow('Failed to insert flyer into database.');
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow('Failed to insert flyer into database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), flyerData }, 'Database error in insertFlyer');
});
});
@@ -151,7 +156,7 @@ describe('Flyer DB Service', () => {
const mockItems = itemsData.map((item, i) => createMockFlyerItem({ ...item, flyer_item_id: i + 1, flyer_id: 1 }));
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await flyerRepo.insertFlyerItems(1, itemsData);
const result = await flyerRepo.insertFlyerItems(1, itemsData, mockLogger);
expect(result).toEqual(mockItems);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
@@ -165,7 +170,7 @@ describe('Flyer DB Service', () => {
});
it('should return an empty array and not query the DB if items array is empty', async () => {
const result = await flyerRepo.insertFlyerItems(1, []);
const result = await flyerRepo.insertFlyerItems(1, [], mockLogger);
expect(result).toEqual([]);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
});
@@ -176,15 +181,17 @@ describe('Flyer DB Service', () => {
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyerItems(999, itemsData)).rejects.toThrow(ForeignKeyConstraintError);
await expect(flyerRepo.insertFlyerItems(999, itemsData)).rejects.toThrow('The specified flyer does not exist.');
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow('The specified flyer does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 999 }, 'Database error in insertFlyerItems');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// The implementation now re-throws the original error, so we should expect that.
await expect(flyerRepo.insertFlyerItems(1, [{ item: 'Test' } as FlyerItemInsert])).rejects.toThrow(dbError);
await expect(flyerRepo.insertFlyerItems(1, [{ item: 'Test' } as FlyerItemInsert], mockLogger)).rejects.toThrow(dbError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 1 }, 'Database error in insertFlyerItems');
});
});
@@ -212,7 +219,7 @@ describe('Flyer DB Service', () => {
return callback(mockClient as any);
});
const result = await createFlyerAndItems(flyerData, itemsData);
const result = await createFlyerAndItems(flyerData, itemsData, mockLogger);
expect(result).toEqual({
flyer: mockFlyer,
@@ -248,7 +255,8 @@ describe('Flyer DB Service', () => {
});
// The transactional function re-throws the original error from the failed step.
await expect(createFlyerAndItems(flyerData, itemsData)).rejects.toThrow(dbError);
await expect(createFlyerAndItems(flyerData, itemsData, mockLogger)).rejects.toThrow(dbError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database transaction error in createFlyerAndItems');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});
@@ -258,7 +266,7 @@ describe('Flyer DB Service', () => {
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Test Brand' })];
mockPoolInstance.query.mockResolvedValue({ rows: mockBrands });
const result = await flyerRepo.getAllBrands();
const result = await flyerRepo.getAllBrands(mockLogger);
expect(result).toEqual(mockBrands);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.stores s'));
@@ -267,7 +275,8 @@ describe('Flyer DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getAllBrands()).rejects.toThrow('Failed to retrieve brands from database.');
await expect(flyerRepo.getAllBrands(mockLogger)).rejects.toThrow('Failed to retrieve brands from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllBrands');
});
});
@@ -276,16 +285,16 @@ describe('Flyer DB Service', () => {
const mockFlyer = createMockFlyer({ flyer_id: 123 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await flyerRepo.getFlyerById(123);
const result = await flyerRepo.getFlyerById(123, mockLogger);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE flyer_id = $1', [123]);
});
it('should throw NotFoundError if flyer is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(flyerRepo.getFlyerById(999)).rejects.toThrow(NotFoundError);
await expect(flyerRepo.getFlyerById(999)).rejects.toThrow('Flyer with ID 999 not found.');
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(flyerRepo.getFlyerById(999, mockLogger)).rejects.toThrow(NotFoundError);
await expect(flyerRepo.getFlyerById(999, mockLogger)).rejects.toThrow('Flyer with ID 999 not found.');
});
});
@@ -294,7 +303,7 @@ describe('Flyer DB Service', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
await flyerRepo.getFlyers();
await flyerRepo.getFlyers(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
@@ -306,7 +315,7 @@ describe('Flyer DB Service', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
await flyerRepo.getFlyers(10, 5);
await flyerRepo.getFlyers(mockLogger, 10, 5);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
@@ -317,7 +326,8 @@ describe('Flyer DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyers()).rejects.toThrow('Failed to retrieve flyers from database.');
await expect(flyerRepo.getFlyers(mockLogger)).rejects.toThrow('Failed to retrieve flyers from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, limit: 20, offset: 0 }, 'Database error in getFlyers');
});
});
@@ -326,7 +336,7 @@ describe('Flyer DB Service', () => {
const mockItems: FlyerItem[] = [createMockFlyerItem({ flyer_id: 456 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await flyerRepo.getFlyerItems(456);
const result = await flyerRepo.getFlyerItems(456, mockLogger);
expect(result).toEqual(mockItems);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE flyer_id = $1'), [456]);
@@ -334,55 +344,58 @@ describe('Flyer DB Service', () => {
it('should return an empty array if flyer has no items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await flyerRepo.getFlyerItems(456);
const result = await flyerRepo.getFlyerItems(456, mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyerItems(456)).rejects.toThrow('Failed to retrieve flyer items from database.');
await expect(flyerRepo.getFlyerItems(456, mockLogger)).rejects.toThrow('Failed to retrieve flyer items from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 456 }, 'Database error in getFlyerItems');
});
});
describe('getFlyerItemsForFlyers', () => {
it('should return items for multiple flyers using ANY', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await flyerRepo.getFlyerItemsForFlyers([1, 2, 3]);
await flyerRepo.getFlyerItemsForFlyers([1, 2, 3], mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('flyer_id = ANY($1::int[])'), [[1, 2, 3]]);
});
it('should return an empty array if no items are found for the given flyer IDs', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await flyerRepo.getFlyerItemsForFlyers([1, 2, 3]);
const result = await flyerRepo.getFlyerItemsForFlyers([1, 2, 3], mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyerItemsForFlyers([1, 2, 3])).rejects.toThrow('Failed to retrieve flyer items in batch from database.');
await expect(flyerRepo.getFlyerItemsForFlyers([1, 2, 3], mockLogger)).rejects.toThrow('Failed to retrieve flyer items in batch from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerIds: [1, 2, 3] }, 'Database error in getFlyerItemsForFlyers');
});
});
describe('countFlyerItemsForFlyers', () => {
it('should return the total count of items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ count: '42' }] });
const result = await flyerRepo.countFlyerItemsForFlyers([1, 2]);
const result = await flyerRepo.countFlyerItemsForFlyers([1, 2], mockLogger);
expect(result).toBe(42);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT COUNT(*)'), [[1, 2]]);
});
it('should return 0 if the flyerIds array is empty', async () => {
// The implementation should short-circuit and return 0 without a query.
const result = await flyerRepo.countFlyerItemsForFlyers([]);
const result = await flyerRepo.countFlyerItemsForFlyers([], mockLogger);
expect(result).toBe(0);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.countFlyerItemsForFlyers([1, 2])).rejects.toThrow('Failed to count flyer items in batch from database.');
await expect(flyerRepo.countFlyerItemsForFlyers([1, 2], mockLogger)).rejects.toThrow('Failed to count flyer items in batch from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerIds: [1, 2] }, 'Database error in countFlyerItemsForFlyers');
});
});
@@ -390,34 +403,35 @@ describe('Flyer DB Service', () => {
it('should return a flyer for a given checksum', async () => {
const mockFlyer = createMockFlyer({ checksum: 'abc' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await flyerRepo.findFlyerByChecksum('abc');
const result = await flyerRepo.findFlyerByChecksum('abc', mockLogger);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE checksum = $1', ['abc']);
});
it('should return undefined if no flyer is found for the checksum', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await flyerRepo.findFlyerByChecksum('not-found');
const result = await flyerRepo.findFlyerByChecksum('not-found', mockLogger);
expect(result).toBeUndefined();
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.findFlyerByChecksum('abc')).rejects.toThrow('Failed to find flyer by checksum in database.');
await expect(flyerRepo.findFlyerByChecksum('abc', mockLogger)).rejects.toThrow('Failed to find flyer by checksum in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, checksum: 'abc' }, 'Database error in findFlyerByChecksum');
});
});
describe('trackFlyerItemInteraction', () => {
it('should increment view_count for a "view" interaction', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await flyerRepo.trackFlyerItemInteraction(101, 'view');
await flyerRepo.trackFlyerItemInteraction(101, 'view', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SET view_count = view_count + 1'), [101]);
});
it('should increment click_count for a "click" interaction', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await flyerRepo.trackFlyerItemInteraction(102, 'click');
await flyerRepo.trackFlyerItemInteraction(102, 'click', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SET click_count = click_count + 1'), [102]);
});
@@ -425,7 +439,8 @@ describe('Flyer DB Service', () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// The function is designed to swallow errors, so we expect it to resolve.
await expect(flyerRepo.trackFlyerItemInteraction(103, 'view')).resolves.toBeUndefined();
await expect(flyerRepo.trackFlyerItemInteraction(103, 'view', mockLogger)).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerItemId: 103, interactionType: 'view' }, 'Database error in trackFlyerItemInteraction (non-critical)');
});
});
@@ -436,7 +451,7 @@ describe('Flyer DB Service', () => {
return callback(mockClient as any);
});
await flyerRepo.deleteFlyer(42);
await flyerRepo.deleteFlyer(42, mockLogger);
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
@@ -451,7 +466,8 @@ describe('Flyer DB Service', () => {
throw new NotFoundError('Simulated re-throw');
});
await expect(flyerRepo.deleteFlyer(999)).rejects.toThrow('Failed to delete flyer.');
await expect(flyerRepo.deleteFlyer(999, mockLogger)).rejects.toThrow('Failed to delete flyer.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(NotFoundError) }, 'Database transaction error in deleteFlyer');
});
it('should rollback transaction on generic error', async () => {
@@ -462,7 +478,8 @@ describe('Flyer DB Service', () => {
throw dbError;
});
await expect(flyerRepo.deleteFlyer(42)).rejects.toThrow('Failed to delete flyer.');
await expect(flyerRepo.deleteFlyer(42, mockLogger)).rejects.toThrow('Failed to delete flyer.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database transaction error in deleteFlyer');
});
});
});

View File

@@ -1,7 +1,7 @@
// src/services/db/flyer.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Flyer, FlyerItem, FlyerInsert, FlyerItemInsert, Brand, FlyerDbInsert } from '../../types';
@@ -18,7 +18,7 @@ export class FlyerRepository {
* @param storeName The name of the store.
* @returns A promise that resolves to the store's ID.
*/
async findOrCreateStore(storeName: string): Promise<number> {
async findOrCreateStore(storeName: string, logger: Logger): Promise<number> {
// Note: This method should be called within a transaction if the caller
// needs to ensure atomicity with other operations.
try {
@@ -36,11 +36,11 @@ export class FlyerRepository {
// Check for a unique constraint violation on name, which could happen in a race condition
// if two processes try to create the same store at the same time.
if (error instanceof Error && 'code' in error && error.code === '23505') {
logger.warn(`Race condition avoided: Store "${storeName}" was created by another process. Refetching.`);
logger.warn({ storeName }, `Race condition avoided: Store was created by another process. Refetching.`);
const result = await this.db.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [storeName]);
if (result.rows.length > 0) return result.rows[0].store_id;
}
logger.error('Database error in findOrCreateStore:', { error, storeName });
logger.error({ err: error, storeName }, 'Database error in findOrCreateStore');
throw new Error('Failed to find or create store in database.');
}
}
@@ -50,7 +50,7 @@ export class FlyerRepository {
* @param flyerData - The data for the new flyer.
* @returns The newly created flyer record with its ID.
*/
async insertFlyer(flyerData: FlyerDbInsert): Promise<Flyer> {
async insertFlyer(flyerData: FlyerDbInsert, logger: Logger): Promise<Flyer> {
try {
const query = `
INSERT INTO flyers (
@@ -76,7 +76,7 @@ export class FlyerRepository {
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
} catch (error) {
logger.error('Database error in insertFlyer:', { error, flyerData });
logger.error({ err: error, flyerData }, 'Database error in insertFlyer');
// Check for a unique constraint violation on the 'checksum' column.
if (error instanceof Error && 'code' in error && error.code === '23505') {
throw new UniqueConstraintError('A flyer with this checksum already exists.');
@@ -91,7 +91,7 @@ export class FlyerRepository {
* @param items - An array of item data to insert.
* @returns An array of the newly created flyer item records.
*/
async insertFlyerItems(flyerId: number, items: FlyerItemInsert[]): Promise<FlyerItem[]> {
async insertFlyerItems(flyerId: number, items: FlyerItemInsert[], logger: Logger): Promise<FlyerItem[]> {
try {
if (!items || items.length === 0) {
return [];
@@ -117,7 +117,7 @@ export class FlyerRepository {
const result = await this.db.query<FlyerItem>(query, values);
return result.rows;
} catch (error) {
logger.error('Database error in insertFlyerItems:', { error, flyerId });
logger.error({ err: error, flyerId }, 'Database error in insertFlyerItems');
// Check for a foreign key violation, which would mean the flyerId is invalid.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified flyer does not exist.');
@@ -134,7 +134,7 @@ export class FlyerRepository {
* Retrieves all distinct brands from the stores table.
* @returns A promise that resolves to an array of Brand objects.
*/
async getAllBrands(): Promise<Brand[]> {
async getAllBrands(logger: Logger): Promise<Brand[]> {
try {
const query = `
SELECT s.store_id as brand_id, s.name, s.logo_url
@@ -144,7 +144,7 @@ export class FlyerRepository {
const res = await this.db.query<Brand>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getAllBrands:', { error });
logger.error({ err: error }, 'Database error in getAllBrands');
throw new Error('Failed to retrieve brands from database.');
}
}
@@ -154,7 +154,7 @@ export class FlyerRepository {
* @param flyerId The ID of the flyer to retrieve.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async getFlyerById(flyerId: number): Promise<Flyer> {
async getFlyerById(flyerId: number, logger: Logger): Promise<Flyer> {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [flyerId]);
if (res.rowCount === 0) throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
return res.rows[0];
@@ -166,12 +166,12 @@ export class FlyerRepository {
* @param offset The number of flyers to skip.
* @returns A promise that resolves to an array of Flyer objects.
*/
async getFlyers(limit: number = 20, offset: number = 0): Promise<Flyer[]> {
async getFlyers(logger: Logger, limit: number = 20, offset: number = 0): Promise<Flyer[]> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyers:', { error, limit, offset });
logger.error({ err: error, limit, offset }, 'Database error in getFlyers');
throw new Error('Failed to retrieve flyers from database.');
}
}
@@ -181,12 +181,12 @@ export class FlyerRepository {
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
async getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
async getFlyerItems(flyerId: number, logger: Logger): Promise<FlyerItem[]> {
try {
const res = await this.db.query<FlyerItem>('SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC', [flyerId]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyerItems:', { error, flyerId });
logger.error({ err: error, flyerId }, 'Database error in getFlyerItems');
throw new Error('Failed to retrieve flyer items from database.');
}
}
@@ -196,12 +196,12 @@ export class FlyerRepository {
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to an array of all matching FlyerItem objects.
*/
async getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
async getFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<FlyerItem[]> {
try {
const res = await this.db.query<FlyerItem>('SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC', [flyerIds]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyerItemsForFlyers:', { error, flyerIds });
logger.error({ err: error, flyerIds }, 'Database error in getFlyerItemsForFlyers');
throw new Error('Failed to retrieve flyer items in batch from database.');
}
}
@@ -211,7 +211,7 @@ export class FlyerRepository {
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to the total count of items.
*/
async countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
async countFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<number> {
try {
if (flyerIds.length === 0) {
return 0;
@@ -219,7 +219,7 @@ export class FlyerRepository {
const res = await this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])', [flyerIds]);
return parseInt(res.rows[0].count, 10);
} catch (error) {
logger.error('Database error in countFlyerItemsForFlyers:', { error, flyerIds });
logger.error({ err: error, flyerIds }, 'Database error in countFlyerItemsForFlyers');
throw new Error('Failed to count flyer items in batch from database.');
}
}
@@ -229,12 +229,12 @@ export class FlyerRepository {
* @param checksum The checksum of the flyer file to find.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
async findFlyerByChecksum(checksum: string, logger: Logger): Promise<Flyer | undefined> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
return res.rows[0];
} catch (error) {
logger.error('Database error in findFlyerByChecksum:', { error, checksum });
logger.error({ err: error, checksum }, 'Database error in findFlyerByChecksum');
throw new Error('Failed to find flyer by checksum in database.');
}
}
@@ -245,7 +245,7 @@ export class FlyerRepository {
* @param flyerItemId The ID of the flyer item.
* @param interactionType The type of interaction, either 'view' or 'click'.
*/
async trackFlyerItemInteraction(flyerItemId: number, interactionType: 'view' | 'click'): Promise<void> {
async trackFlyerItemInteraction(flyerItemId: number, interactionType: 'view' | 'click', logger: Logger): Promise<void> {
try {
// Choose the column to increment based on the interaction type.
// This is safe from SQL injection as the input is strictly controlled to be 'view' or 'click'.
@@ -258,7 +258,7 @@ export class FlyerRepository {
`;
await this.db.query(query, [flyerItemId]);
} catch (error) {
logger.error('Database error in trackFlyerItemInteraction (non-critical):', { error, flyerItemId, interactionType });
logger.error({ err: error, flyerItemId, interactionType }, 'Database error in trackFlyerItemInteraction (non-critical)');
}
}
@@ -267,7 +267,7 @@ export class FlyerRepository {
* This should typically be an admin-only action.
* @param flyerId The ID of the flyer to delete.
*/
async deleteFlyer(flyerId: number): Promise<void> {
async deleteFlyer(flyerId: number, logger: Logger): Promise<void> {
try {
await withTransaction(async (client) => {
// The schema is set up with ON DELETE CASCADE for flyer_items,
@@ -280,7 +280,7 @@ export class FlyerRepository {
logger.info(`Successfully deleted flyer with ID: ${flyerId}`);
});
} catch (error) {
logger.error('Database transaction error in deleteFlyer:', { error, flyerId });
logger.error({ err: error, flyerId }, 'Database transaction error in deleteFlyer');
throw new Error('Failed to delete flyer.');
}
}
@@ -293,27 +293,27 @@ export class FlyerRepository {
* @param itemsForDb - An array of item data to associate with the flyer.
* @returns An object containing the new flyer and its items.
*/
export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: FlyerItemInsert[]) {
export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: FlyerItemInsert[], logger: Logger) {
try {
return await withTransaction(async (client) => {
const flyerRepo = new FlyerRepository(client);
// 1. Find or create the store to get the store_id
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name);
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);
// 2. Prepare the data for the flyer table, replacing store_name with store_id
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
// 3. Insert the flyer record
const newFlyer = await flyerRepo.insertFlyer(flyerDbData);
const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);
// 4. Insert the associated flyer items
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb);
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);
return { flyer: newFlyer, items: newItems };
});
} catch (error) {
logger.error('Database transaction error in createFlyerAndItems:', { error });
logger.error({ err: error }, 'Database transaction error in createFlyerAndItems');
throw error; // Re-throw the error to be handled by the calling service.
}
}

View File

@@ -17,6 +17,7 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
describe('Gamification DB Service', () => {
let gamificationRepo: GamificationRepository;
@@ -35,7 +36,7 @@ describe('Gamification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockAchievements });
const result = await gamificationRepo.getAllAchievements();
const result = await gamificationRepo.getAllAchievements(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
expect(result).toEqual(mockAchievements);
@@ -44,7 +45,8 @@ describe('Gamification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(gamificationRepo.getAllAchievements()).rejects.toThrow('Failed to retrieve achievements.');
await expect(gamificationRepo.getAllAchievements(mockLogger)).rejects.toThrow('Failed to retrieve achievements.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllAchievements');
});
});
@@ -55,7 +57,7 @@ describe('Gamification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockUserAchievements });
const result = await gamificationRepo.getUserAchievements('user-123');
const result = await gamificationRepo.getUserAchievements('user-123', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']);
expect(result).toEqual(mockUserAchievements);
@@ -64,14 +66,15 @@ describe('Gamification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(gamificationRepo.getUserAchievements('user-123')).rejects.toThrow('Failed to retrieve user achievements.');
await expect(gamificationRepo.getUserAchievements('user-123', mockLogger)).rejects.toThrow('Failed to retrieve user achievements.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserAchievements');
});
});
describe('awardAchievement', () => {
it('should call the award_achievement database function with the correct parameters', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // The function returns void
await gamificationRepo.awardAchievement('user-123', 'Test Achievement');
await gamificationRepo.awardAchievement('user-123', 'Test Achievement', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']);
});
@@ -80,13 +83,15 @@ describe('Gamification DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(gamificationRepo.awardAchievement('non-existent-user', 'Non-existent Achievement')).rejects.toThrow('The specified user or achievement does not exist.');
await expect(gamificationRepo.awardAchievement('non-existent-user', 'Non-existent Achievement', mockLogger)).rejects.toThrow('The specified user or achievement does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', achievementName: 'Non-existent Achievement' }, 'Database error in awardAchievement');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(gamificationRepo.awardAchievement('user-123', 'Test Achievement')).rejects.toThrow('Failed to award achievement.');
await expect(gamificationRepo.awardAchievement('user-123', 'Test Achievement', mockLogger)).rejects.toThrow('Failed to award achievement.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', achievementName: 'Test Achievement' }, 'Database error in awardAchievement');
});
});
@@ -98,7 +103,7 @@ describe('Gamification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockLeaderboard });
const result = await gamificationRepo.getLeaderboard(10);
const result = await gamificationRepo.getLeaderboard(10, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]);
@@ -108,7 +113,8 @@ describe('Gamification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(gamificationRepo.getLeaderboard(10)).rejects.toThrow('Failed to retrieve leaderboard.');
await expect(gamificationRepo.getLeaderboard(10, mockLogger)).rejects.toThrow('Failed to retrieve leaderboard.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, limit: 10 }, 'Database error in getLeaderboard');
});
});
});

View File

@@ -2,7 +2,7 @@
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import { Achievement, UserAchievement, LeaderboardUser } from '../../types';
export class GamificationRepository {
@@ -16,12 +16,12 @@ export class GamificationRepository {
* Retrieves the master list of all available achievements.
* @returns A promise that resolves to an array of Achievement objects.
*/
async getAllAchievements(): Promise<Achievement[]> {
async getAllAchievements(logger: Logger): Promise<Achievement[]> {
try {
const res = await this.db.query<Achievement>('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getAllAchievements:', { error });
logger.error({ err: error }, 'Database error in getAllAchievements');
throw new Error('Failed to retrieve achievements.');
}
}
@@ -32,7 +32,7 @@ export class GamificationRepository {
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Achievement objects earned by the user.
*/
async getUserAchievements(userId: string): Promise<(UserAchievement & Achievement)[]> {
async getUserAchievements(userId: string, logger: Logger): Promise<(UserAchievement & Achievement)[]> {
try {
const query = `
SELECT
@@ -51,7 +51,7 @@ export class GamificationRepository {
const res = await this.db.query<(UserAchievement & Achievement)>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserAchievements:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getUserAchievements');
throw new Error('Failed to retrieve user achievements.');
}
}
@@ -64,7 +64,7 @@ export class GamificationRepository {
* @param achievementName The name of the achievement to award.
* @returns A promise that resolves when the operation is complete.
*/
async awardAchievement(userId: string, achievementName: string): Promise<void> {
async awardAchievement(userId: string, achievementName: string, logger: Logger): Promise<void> {
try {
await this.db.query("SELECT public.award_achievement($1, $2)", [userId, achievementName]);
} catch (error) {
@@ -72,7 +72,7 @@ export class GamificationRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or achievement does not exist.');
}
logger.error('Database error in awardAchievement:', { error, userId, achievementName });
logger.error({ err: error, userId, achievementName }, 'Database error in awardAchievement');
throw new Error('Failed to award achievement.');
}
}
@@ -82,7 +82,7 @@ export class GamificationRepository {
* @param limit The number of users to return.
* @returns A promise that resolves to an array of leaderboard user objects.
*/
async getLeaderboard(limit: number): Promise<LeaderboardUser[]> {
async getLeaderboard(limit: number, logger: Logger): Promise<LeaderboardUser[]> {
try {
const query = `
SELECT
@@ -98,7 +98,7 @@ export class GamificationRepository {
const res = await this.db.query<LeaderboardUser>(query, [limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getLeaderboard:', { error, limit });
logger.error({ err: error, limit }, 'Database error in getLeaderboard');
throw new Error('Failed to retrieve leaderboard.');
}
}

View File

@@ -6,7 +6,7 @@ vi.unmock('./notification.db');
import { NotificationRepository } from './notification.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { ForeignKeyConstraintError } from './errors.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Notification } from '../../types';
// Mock the logger to prevent console output during tests
@@ -18,6 +18,7 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
describe('Notification DB Service', () => {
let notificationRepo: NotificationRepository;
@@ -35,7 +36,7 @@ describe('Notification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockNotifications });
const result = await notificationRepo.getNotificationsForUser('user-123', 10, 5);
const result = await notificationRepo.getNotificationsForUser('user-123', 10, 5, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.notifications'),
@@ -46,7 +47,7 @@ describe('Notification DB Service', () => {
it('should return an empty array if the user has no notifications', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await notificationRepo.getNotificationsForUser('user-456', 10, 0);
const result = await notificationRepo.getNotificationsForUser('user-456', 10, 0, mockLogger);
expect(result).toEqual([]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), ['user-456', 10, 0]);
});
@@ -54,7 +55,8 @@ describe('Notification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(notificationRepo.getNotificationsForUser('user-123', 10, 5)).rejects.toThrow('Failed to retrieve notifications.');
await expect(notificationRepo.getNotificationsForUser('user-123', 10, 5, mockLogger)).rejects.toThrow('Failed to retrieve notifications.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', limit: 10, offset: 5 }, 'Database error in getNotificationsForUser');
});
});
@@ -63,7 +65,7 @@ describe('Notification DB Service', () => {
const mockNotification: Notification = { notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification] });
const result = await notificationRepo.createNotification('user-123', 'Test');
const result = await notificationRepo.createNotification('user-123', 'Test', mockLogger);
expect(result).toEqual(mockNotification);
});
@@ -71,7 +73,7 @@ describe('Notification DB Service', () => {
const mockNotification: Notification = { notification_id: 2, user_id: 'user-123', content: 'Test with link', link_url: '/some/link', is_read: false, created_at: '' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification] });
const result = await notificationRepo.createNotification('user-123', 'Test with link', '/some/link');
const result = await notificationRepo.createNotification('user-123', 'Test with link', mockLogger, '/some/link');
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.notifications'),
['user-123', 'Test with link', '/some/link']
@@ -83,13 +85,15 @@ describe('Notification DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValueOnce(dbError);
await expect(notificationRepo.createNotification('non-existent-user', 'Test')).rejects.toThrow(ForeignKeyConstraintError);
await expect(notificationRepo.createNotification('non-existent-user', 'Test', mockLogger)).rejects.toThrow('The specified user does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', content: 'Test', linkUrl: undefined }, 'Database error in createNotification');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(notificationRepo.createNotification('user-123', 'Test')).rejects.toThrow('Failed to create notification.');
await expect(notificationRepo.createNotification('user-123', 'Test', mockLogger)).rejects.toThrow('Failed to create notification.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', content: 'Test', linkUrl: undefined }, 'Database error in createNotification');
});
});
@@ -98,7 +102,7 @@ describe('Notification DB Service', () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const notificationsToCreate = [{ user_id: 'u1', content: "msg" }];
await notificationRepo.createBulkNotifications(notificationsToCreate);
await notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger);
// Check that the query was called with the correct unnest structure
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])'),
@@ -107,7 +111,7 @@ describe('Notification DB Service', () => {
});
it('should not query the database if the notifications array is empty', async () => {
await notificationRepo.createBulkNotifications([]);
await notificationRepo.createBulkNotifications([], mockLogger);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
});
@@ -116,14 +120,16 @@ describe('Notification DB Service', () => {
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
const notificationsToCreate = [{ user_id: 'non-existent', content: "msg" }];
await expect(notificationRepo.createBulkNotifications(notificationsToCreate)).rejects.toThrow('One or more of the specified users do not exist.');
await expect(notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in createBulkNotifications');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
const notificationsToCreate = [{ user_id: 'u1', content: "msg" }];
await expect(notificationRepo.createBulkNotifications(notificationsToCreate)).rejects.toThrow('Failed to create bulk notifications.');
await expect(notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger)).rejects.toThrow('Failed to create bulk notifications.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in createBulkNotifications');
});
});
@@ -132,7 +138,7 @@ describe('Notification DB Service', () => {
const mockNotification: Notification = { notification_id: 123, user_id: 'abc', content: 'msg', is_read: true, created_at: '' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification], rowCount: 1 });
const result = await notificationRepo.markNotificationAsRead(123, 'abc');
const result = await notificationRepo.markNotificationAsRead(123, 'abc', mockLogger);
expect(result).toEqual(mockNotification);
});
@@ -140,8 +146,8 @@ describe('Notification DB Service', () => {
// FIX: Ensure rowCount is 0
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(notificationRepo.markNotificationAsRead(999, 'abc'))
.rejects.toThrow('Notification not found or user does not have permission.');
await expect(notificationRepo.markNotificationAsRead(999, 'abc', mockLogger))
.rejects.toThrow(NotFoundError);
});
it('should re-throw the specific "not found" error if it occurs', async () => {
@@ -151,20 +157,21 @@ describe('Notification DB Service', () => {
throw notFoundError;
});
await expect(notificationRepo.markNotificationAsRead(999, 'user-abc')).rejects.toThrow(notFoundError);
await expect(notificationRepo.markNotificationAsRead(999, 'user-abc', mockLogger)).rejects.toThrow(notFoundError);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(notificationRepo.markNotificationAsRead(123, 'abc')).rejects.toThrow('Failed to mark notification as read.');
await expect(notificationRepo.markNotificationAsRead(123, 'abc', mockLogger)).rejects.toThrow('Failed to mark notification as read.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, notificationId: 123, userId: 'abc' }, 'Database error in markNotificationAsRead');
});
});
describe('markAllNotificationsAsRead', () => {
it('should execute an UPDATE query to mark all notifications as read for a user', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 3 });
await notificationRepo.markAllNotificationsAsRead('user-xyz');
await notificationRepo.markAllNotificationsAsRead('user-xyz', mockLogger);
// Fix expected arguments to match what the implementation actually sends
// The implementation likely passes the user ID
@@ -177,14 +184,15 @@ describe('Notification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(notificationRepo.markAllNotificationsAsRead('user-xyz')).rejects.toThrow('Failed to mark notifications as read.');
await expect(notificationRepo.markAllNotificationsAsRead('user-xyz', mockLogger)).rejects.toThrow('Failed to mark notifications as read.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-xyz' }, 'Database error in markAllNotificationsAsRead');
});
});
describe('deleteOldNotifications', () => {
it('should execute a DELETE query and return the number of deleted rows', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 5 });
const result = await notificationRepo.deleteOldNotifications(30);
const result = await notificationRepo.deleteOldNotifications(30, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
[30]
@@ -194,14 +202,15 @@ describe('Notification DB Service', () => {
it('should return 0 if rowCount is null or undefined', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: null });
const result = await notificationRepo.deleteOldNotifications(30);
const result = await notificationRepo.deleteOldNotifications(30, mockLogger);
expect(result).toBe(0);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(notificationRepo.deleteOldNotifications(30)).rejects.toThrow('Failed to delete old notifications.');
await expect(notificationRepo.deleteOldNotifications(30, mockLogger)).rejects.toThrow('Failed to delete old notifications.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, daysOld: 30 }, 'Database error in deleteOldNotifications');
});
});
});

View File

@@ -2,8 +2,8 @@
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import { Notification } from '../../types';
import type { Logger } from 'pino';
import type { Notification } from '../../types';
export class NotificationRepository {
private db: Pool | PoolClient;
@@ -19,7 +19,7 @@ export class NotificationRepository {
* @param linkUrl An optional URL for the notification to link to.
* @returns A promise that resolves to the newly created Notification object.
*/
async createNotification(userId: string, content: string, linkUrl?: string): Promise<Notification> {
async createNotification(userId: string, content: string, logger: Logger, linkUrl?: string): Promise<Notification> {
try {
const res = await this.db.query<Notification>(
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
@@ -30,7 +30,7 @@ export class NotificationRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createNotification:', { error });
logger.error({ err: error, userId, content, linkUrl }, 'Database error in createNotification');
throw new Error('Failed to create notification.');
}
}
@@ -40,7 +40,7 @@ export class NotificationRepository {
* This is more efficient than inserting one by one.
* @param notifications An array of notification objects to be inserted.
*/
async createBulkNotifications(notifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[]): Promise<void> {
async createBulkNotifications(notifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[], logger: Logger): Promise<void> {
if (notifications.length === 0) {
return;
}
@@ -65,7 +65,7 @@ export class NotificationRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or more of the specified users do not exist.');
}
logger.error('Database error in createBulkNotifications:', { error });
logger.error({ err: error }, 'Database error in createBulkNotifications');
throw new Error('Failed to create bulk notifications.');
}
}
@@ -77,7 +77,7 @@ export class NotificationRepository {
* @param offset The number of notifications to skip for pagination.
* @returns A promise that resolves to an array of Notification objects.
*/
async getNotificationsForUser(userId: string, limit: number, offset: number): Promise<Notification[]> {
async getNotificationsForUser(userId: string, limit: number, offset: number, logger: Logger): Promise<Notification[]> {
try {
const res = await this.db.query<Notification>(
`SELECT * FROM public.notifications
@@ -88,7 +88,7 @@ export class NotificationRepository {
);
return res.rows;
} catch (error) {
logger.error('Database error in getNotificationsForUser:', { error, userId });
logger.error({ err: error, userId, limit, offset }, 'Database error in getNotificationsForUser');
throw new Error('Failed to retrieve notifications.');
}
}
@@ -98,14 +98,14 @@ export class NotificationRepository {
* @param userId The ID of the user whose notifications should be marked as read.
* @returns A promise that resolves when the operation is complete.
*/
async markAllNotificationsAsRead(userId: string): Promise<void> {
async markAllNotificationsAsRead(userId: string, logger: Logger): Promise<void> {
try {
await this.db.query(
`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
[userId]
);
} catch (error) {
logger.error('Database error in markAllNotificationsAsRead:', { error, userId });
logger.error({ err: error, userId }, 'Database error in markAllNotificationsAsRead');
throw new Error('Failed to mark notifications as read.');
}
}
@@ -118,7 +118,7 @@ export class NotificationRepository {
* @returns A promise that resolves to the updated Notification object.
* @throws An error if the notification is not found or does not belong to the user.
*/
async markNotificationAsRead(notificationId: number, userId: string): Promise<Notification> {
async markNotificationAsRead(notificationId: number, userId: string, logger: Logger): Promise<Notification> {
try {
const res = await this.db.query<Notification>(
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
@@ -130,7 +130,7 @@ export class NotificationRepository {
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in markNotificationAsRead:', { error, notificationId, userId });
logger.error({ err: error, notificationId, userId }, 'Database error in markNotificationAsRead');
throw new Error('Failed to mark notification as read.');
}
}
@@ -141,7 +141,7 @@ export class NotificationRepository {
* @param daysOld The minimum age in days for a notification to be deleted.
* @returns A promise that resolves to the number of deleted notifications.
*/
async deleteOldNotifications(daysOld: number): Promise<number> {
async deleteOldNotifications(daysOld: number, logger: Logger): Promise<number> {
try {
const res = await this.db.query(
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
@@ -149,7 +149,7 @@ export class NotificationRepository {
);
return res.rowCount ?? 0;
} catch (error) {
logger.error('Database error in deleteOldNotifications:', { error, daysOld });
logger.error({ err: error, daysOld }, 'Database error in deleteOldNotifications');
throw new Error('Failed to delete old notifications.');
}
}

View File

@@ -27,6 +27,7 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
describe('Personalization DB Service', () => {
let personalizationRepo: PersonalizationRepository;
@@ -47,7 +48,7 @@ describe('Personalization DB Service', () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
mockQuery.mockResolvedValue({ rows: mockItems });
const result = await personalizationRepo.getAllMasterItems();
const result = await personalizationRepo.getAllMasterItems(mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
expect(result).toEqual(mockItems);
@@ -55,14 +56,15 @@ describe('Personalization DB Service', () => {
it('should return an empty array if no master items exist', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getAllMasterItems();
const result = await personalizationRepo.getAllMasterItems(mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getAllMasterItems()).rejects.toThrow('Failed to retrieve master grocery items.');
await expect(personalizationRepo.getAllMasterItems(mockLogger)).rejects.toThrow('Failed to retrieve master grocery items.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllMasterItems');
});
});
@@ -71,7 +73,7 @@ describe('Personalization DB Service', () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
mockQuery.mockResolvedValue({ rows: mockItems });
const result = await personalizationRepo.getWatchedItems('user-123');
const result = await personalizationRepo.getWatchedItems('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items mgi'), ['user-123']);
expect(result).toEqual(mockItems);
@@ -79,14 +81,15 @@ describe('Personalization DB Service', () => {
it('should return an empty array if the user has no watched items', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getWatchedItems('user-123');
const result = await personalizationRepo.getWatchedItems('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getWatchedItems('user-123')).rejects.toThrow('Failed to retrieve watched items.');
await expect(personalizationRepo.getWatchedItems('user-123', mockLogger)).rejects.toThrow('Failed to retrieve watched items.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getWatchedItems');
});
});
@@ -102,7 +105,7 @@ describe('Personalization DB Service', () => {
return callback(mockClient as any);
});
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce');
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce', mockLogger);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT category_id FROM public.categories'), ['Produce']);
@@ -122,7 +125,7 @@ describe('Personalization DB Service', () => {
return callback(mockClient as any);
});
const result = await personalizationRepo.addWatchedItem('user-123', 'Brand New Item', 'Produce');
const result = await personalizationRepo.addWatchedItem('user-123', 'Brand New Item', 'Produce', mockLogger);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.master_grocery_items'), ['Brand New Item', 1]);
@@ -141,7 +144,7 @@ describe('Personalization DB Service', () => {
});
// The function should resolve successfully without throwing an error.
await expect(personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce')).resolves.toEqual(mockExistingItem);
await expect(personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce', mockLogger)).resolves.toEqual(mockExistingItem);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT (user_id, master_item_id) DO NOTHING'), ['user-123', 1]);
});
@@ -153,7 +156,8 @@ describe('Personalization DB Service', () => {
throw new Error("Category 'Fake Category' not found.");
});
await expect(personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category')).rejects.toThrow("Failed to add item to watchlist.");
await expect(personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category', mockLogger)).rejects.toThrow("Failed to add item to watchlist.");
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: 'user-123', itemName: 'Some Item', categoryName: 'Fake Category' }, 'Transaction error in addWatchedItem');
});
it('should throw a generic error on failure', async () => {
@@ -165,7 +169,8 @@ describe('Personalization DB Service', () => {
throw dbError;
});
await expect(personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce')).rejects.toThrow('Failed to add item to watchlist.');
await expect(personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce', mockLogger)).rejects.toThrow('Failed to add item to watchlist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryName: 'Produce' }, 'Transaction error in addWatchedItem');
});
it('should throw ForeignKeyConstraintError on invalid user or category', async () => {
@@ -173,14 +178,15 @@ describe('Personalization DB Service', () => {
(dbError as any).code = '23503';
vi.mocked(withTransaction).mockRejectedValue(dbError);
await expect(personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce')).rejects.toThrow('The specified user or category does not exist.');
await expect(personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce', mockLogger)).rejects.toThrow('The specified user or category does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', itemName: 'Some Item', categoryName: 'Produce' }, 'Transaction error in addWatchedItem');
});
});
describe('removeWatchedItem', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.removeWatchedItem('user-123', 1);
await personalizationRepo.removeWatchedItem('user-123', 1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]
);
});
@@ -188,174 +194,183 @@ describe('Personalization DB Service', () => {
it('should complete without error if the item to remove is not in the watchlist', async () => {
// Simulate the DB returning 0 rows affected
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(personalizationRepo.removeWatchedItem('user-123', 999)).resolves.toBeUndefined();
await expect(personalizationRepo.removeWatchedItem('user-123', 999, mockLogger)).resolves.toBeUndefined();
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.removeWatchedItem('user-123', 1)).rejects.toThrow('Failed to remove item from watchlist.');
await expect(personalizationRepo.removeWatchedItem('user-123', 1, mockLogger)).rejects.toThrow('Failed to remove item from watchlist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', masterItemId: 1 }, 'Database error in removeWatchedItem');
});
});
describe('findRecipesFromPantry', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.findRecipesFromPantry('user-123');
await personalizationRepo.findRecipesFromPantry('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']);
});
it('should return an empty array if no recipes are found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.findRecipesFromPantry('user-123');
const result = await personalizationRepo.findRecipesFromPantry('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.findRecipesFromPantry('user-123')).rejects.toThrow('Failed to find recipes from pantry.');
await expect(personalizationRepo.findRecipesFromPantry('user-123', mockLogger)).rejects.toThrow('Failed to find recipes from pantry.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in findRecipesFromPantry');
});
});
describe('recommendRecipesForUser', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.recommendRecipesForUser('user-123', 5);
await personalizationRepo.recommendRecipesForUser('user-123', 5, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]);
});
it('should return an empty array if no recipes are recommended', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.recommendRecipesForUser('user-123', 5);
const result = await personalizationRepo.recommendRecipesForUser('user-123', 5, mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.recommendRecipesForUser('user-123', 5)).rejects.toThrow('Failed to recommend recipes.');
await expect(personalizationRepo.recommendRecipesForUser('user-123', 5, mockLogger)).rejects.toThrow('Failed to recommend recipes.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', limit: 5 }, 'Database error in recommendRecipesForUser');
});
});
describe('getBestSalePricesForUser', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.getBestSalePricesForUser('user-123');
await personalizationRepo.getBestSalePricesForUser('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']);
});
it('should return an empty array if no deals are found for the user', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getBestSalePricesForUser('user-123');
const result = await personalizationRepo.getBestSalePricesForUser('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getBestSalePricesForUser('user-123')).rejects.toThrow('Failed to get best sale prices.');
await expect(personalizationRepo.getBestSalePricesForUser('user-123', mockLogger)).rejects.toThrow('Failed to get best sale prices.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getBestSalePricesForUser');
});
});
describe('getBestSalePricesForAllUsers', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.getBestSalePricesForAllUsers();
await personalizationRepo.getBestSalePricesForAllUsers(mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()');
});
it('should return an empty array if no deals are found for any user', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getBestSalePricesForAllUsers();
const result = await personalizationRepo.getBestSalePricesForAllUsers(mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getBestSalePricesForAllUsers()).rejects.toThrow('Failed to get best sale prices for all users.');
await expect(personalizationRepo.getBestSalePricesForAllUsers(mockLogger)).rejects.toThrow('Failed to get best sale prices for all users.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getBestSalePricesForAllUsers');
});
});
describe('suggestPantryItemConversions', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.suggestPantryItemConversions(1);
await personalizationRepo.suggestPantryItemConversions(1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]);
});
it('should return an empty array if no conversions are suggested', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.suggestPantryItemConversions(1);
const result = await personalizationRepo.suggestPantryItemConversions(1, mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.suggestPantryItemConversions(1)).rejects.toThrow('Failed to suggest pantry item conversions.');
await expect(personalizationRepo.suggestPantryItemConversions(1, mockLogger)).rejects.toThrow('Failed to suggest pantry item conversions.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, pantryItemId: 1 }, 'Database error in suggestPantryItemConversions');
});
});
describe('findPantryItemOwner', () => {
it('should execute a SELECT query to find the owner', async () => {
mockQuery.mockResolvedValue({ rows: [{ user_id: 'user-123' }] });
const result = await personalizationRepo.findPantryItemOwner(1);
const result = await personalizationRepo.findPantryItemOwner(1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]);
expect(result?.user_id).toBe('user-123');
});
it('should return undefined if the pantry item is not found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.findPantryItemOwner(999);
const result = await personalizationRepo.findPantryItemOwner(999, mockLogger);
expect(result).toBeUndefined();
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.findPantryItemOwner(1)).rejects.toThrow('Failed to retrieve pantry item owner from database.');
await expect(personalizationRepo.findPantryItemOwner(1, mockLogger)).rejects.toThrow('Failed to retrieve pantry item owner from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, pantryItemId: 1 }, 'Database error in findPantryItemOwner');
});
});
describe('getDietaryRestrictions', () => {
it('should execute a SELECT query to get all restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
await personalizationRepo.getDietaryRestrictions();
await personalizationRepo.getDietaryRestrictions(mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
});
it('should return an empty array if no restrictions exist', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getDietaryRestrictions();
const result = await personalizationRepo.getDietaryRestrictions(mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getDietaryRestrictions()).rejects.toThrow('Failed to get dietary restrictions.');
await expect(personalizationRepo.getDietaryRestrictions(mockLogger)).rejects.toThrow('Failed to get dietary restrictions.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getDietaryRestrictions');
});
});
describe('getUserDietaryRestrictions', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
await personalizationRepo.getUserDietaryRestrictions('user-123');
await personalizationRepo.getUserDietaryRestrictions('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.dietary_restrictions dr'), ['user-123']);
});
it('should return an empty array if the user has no restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
const result = await personalizationRepo.getUserDietaryRestrictions('user-123');
const result = await personalizationRepo.getUserDietaryRestrictions('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getUserDietaryRestrictions('user-123')).rejects.toThrow('Failed to get user dietary restrictions.');
await expect(personalizationRepo.getUserDietaryRestrictions('user-123', mockLogger)).rejects.toThrow('Failed to get user dietary restrictions.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserDietaryRestrictions');
});
describe('setUserDietaryRestrictions', () => {
@@ -365,7 +380,7 @@ describe('Personalization DB Service', () => {
return callback(mockClient as any);
});
await personalizationRepo.setUserDietaryRestrictions('user-123', [1, 2]);
await personalizationRepo.setUserDietaryRestrictions('user-123', [1, 2], mockLogger);
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
@@ -383,7 +398,8 @@ describe('Personalization DB Service', () => {
throw dbError;
});
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [999])).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [999], mockLogger)).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', restrictionIds: [999] }, 'Database error in setUserDietaryRestrictions');
});
it('should handle an empty array of restriction IDs', async () => {
@@ -392,7 +408,7 @@ describe('Personalization DB Service', () => {
return callback(mockClient as any);
});
await personalizationRepo.setUserDietaryRestrictions('user-123', []);
await personalizationRepo.setUserDietaryRestrictions('user-123', [], mockLogger);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
@@ -401,7 +417,7 @@ describe('Personalization DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
vi.mocked(withTransaction).mockRejectedValue(new Error('DB Error'));
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [1])).rejects.toThrow('Failed to set user dietary restrictions.');
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [1], mockLogger)).rejects.toThrow('Failed to set user dietary restrictions.');
});
});
});
@@ -409,40 +425,42 @@ describe('Personalization DB Service', () => {
describe('getAppliances', () => {
it('should execute a SELECT query to get all appliances', async () => {
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
await personalizationRepo.getAppliances();
await personalizationRepo.getAppliances(mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.appliances ORDER BY name');
});
it('should return an empty array if no appliances exist', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getAppliances();
const result = await personalizationRepo.getAppliances(mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getAppliances()).rejects.toThrow('Failed to get appliances.');
await expect(personalizationRepo.getAppliances(mockLogger)).rejects.toThrow('Failed to get appliances.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAppliances');
});
});
describe('getUserAppliances', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
await personalizationRepo.getUserAppliances('user-123');
await personalizationRepo.getUserAppliances('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']);
});
it('should return an empty array if the user has no appliances', async () => {
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
const result = await personalizationRepo.getUserAppliances('user-123');
const result = await personalizationRepo.getUserAppliances('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getUserAppliances('user-123')).rejects.toThrow('Failed to get user appliances.');
await expect(personalizationRepo.getUserAppliances('user-123', mockLogger)).rejects.toThrow('Failed to get user appliances.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserAppliances');
});
});
@@ -460,7 +478,7 @@ describe('Personalization DB Service', () => {
return callback(mockClient as any);
});
const result = await personalizationRepo.setUserAppliances('user-123', [1, 2]);
const result = await personalizationRepo.setUserAppliances('user-123', [1, 2], mockLogger);
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
@@ -479,7 +497,8 @@ describe('Personalization DB Service', () => {
throw dbError;
});
await expect(personalizationRepo.setUserAppliances('user-123', [999])).rejects.toThrow(ForeignKeyConstraintError);
await expect(personalizationRepo.setUserAppliances('user-123', [999], mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', applianceIds: [999] }, 'Database error in setUserAppliances');
});
it('should handle an empty array of appliance IDs', async () => {
@@ -488,7 +507,7 @@ describe('Personalization DB Service', () => {
return callback(mockClient as any);
});
const result = await personalizationRepo.setUserAppliances('user-123', []);
const result = await personalizationRepo.setUserAppliances('user-123', [], mockLogger);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
@@ -505,27 +524,29 @@ describe('Personalization DB Service', () => {
throw dbError;
});
await expect(personalizationRepo.setUserAppliances('user-123', [1])).rejects.toThrow('Failed to set user appliances.');
await expect(personalizationRepo.setUserAppliances('user-123', [1], mockLogger)).rejects.toThrow('Failed to set user appliances.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', applianceIds: [1] }, 'Database error in setUserAppliances');
});
});
describe('getRecipesForUserDiets', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await personalizationRepo.getRecipesForUserDiets('user-123');
await personalizationRepo.getRecipesForUserDiets('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']);
});
it('should return an empty array if no recipes match the diet', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getRecipesForUserDiets('user-123');
const result = await personalizationRepo.getRecipesForUserDiets('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getRecipesForUserDiets('user-123')).rejects.toThrow('Failed to get recipes compatible with user diet.');
await expect(personalizationRepo.getRecipesForUserDiets('user-123', mockLogger)).rejects.toThrow('Failed to get recipes compatible with user diet.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getRecipesForUserDiets');
});
});
});

View File

@@ -2,7 +2,7 @@
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import {
MasterGroceryItem,
PantryRecipe,
@@ -26,12 +26,12 @@ export class PersonalizationRepository {
* Retrieves all master grocery items from the database.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
async getAllMasterItems(): Promise<MasterGroceryItem[]> {
async getAllMasterItems(logger: Logger): Promise<MasterGroceryItem[]> {
try {
const res = await this.db.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getAllMasterItems:', { error });
logger.error({ err: error }, 'Database error in getAllMasterItems');
throw new Error('Failed to retrieve master grocery items.');
}
}
@@ -41,7 +41,7 @@ export class PersonalizationRepository {
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
async getWatchedItems(userId: string): Promise<MasterGroceryItem[]> {
async getWatchedItems(userId: string, logger: Logger): Promise<MasterGroceryItem[]> {
try {
const query = `
SELECT mgi.*
@@ -53,7 +53,7 @@ export class PersonalizationRepository {
const res = await this.db.query<MasterGroceryItem>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getWatchedItems:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getWatchedItems');
throw new Error('Failed to retrieve watched items.');
}
}
@@ -63,11 +63,11 @@ export class PersonalizationRepository {
* @param userId The UUID of the user.
* @param masterItemId The ID of the master item to remove.
*/
async removeWatchedItem(userId: string, masterItemId: number): Promise<void> {
async removeWatchedItem(userId: string, masterItemId: number, logger: Logger): Promise<void> {
try {
await this.db.query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]);
} catch (error) {
logger.error('Database error in removeWatchedItem:', { error });
logger.error({ err: error, userId, masterItemId }, 'Database error in removeWatchedItem');
throw new Error('Failed to remove item from watchlist.');
}
}
@@ -77,7 +77,7 @@ export class PersonalizationRepository {
* @param pantryItemId The ID of the pantry item.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
async findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> {
async findPantryItemOwner(pantryItemId: number, logger: Logger): Promise<{ user_id: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string }>(
'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1',
@@ -85,7 +85,7 @@ export class PersonalizationRepository {
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findPantryItemOwner:', { error, pantryItemId });
logger.error({ err: error, pantryItemId }, 'Database error in findPantryItemOwner');
throw new Error('Failed to retrieve pantry item owner from database.');
}
}
@@ -98,7 +98,7 @@ export class PersonalizationRepository {
* @param categoryName The category of the item.
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
*/
async addWatchedItem(userId: string, itemName: string, categoryName: string): Promise<MasterGroceryItem> {
async addWatchedItem(userId: string, itemName: string, categoryName: string, logger: Logger): Promise<MasterGroceryItem> {
try {
return await withTransaction(async (client) => {
// Find category ID
@@ -139,7 +139,7 @@ export class PersonalizationRepository {
throw new ForeignKeyConstraintError('The specified user or category does not exist.');
}
}
logger.error('Transaction error in addWatchedItem:', { error });
logger.error({ err: error, userId, itemName, categoryName }, 'Transaction error in addWatchedItem');
throw new Error('Failed to add item to watchlist.');
}
}
@@ -149,13 +149,13 @@ export class PersonalizationRepository {
* This is much more efficient than calling getBestSalePricesForUser for each user individually.
* @returns A promise that resolves to an array of deals, each augmented with user information.
*/
async getBestSalePricesForAllUsers(): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> {
async getBestSalePricesForAllUsers(logger: Logger): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> {
try {
// This function assumes a corresponding PostgreSQL function `get_best_sale_prices_for_all_users` exists.
const res = await this.db.query<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForAllUsers:', { error });
logger.error({ err: error }, 'Database error in getBestSalePricesForAllUsers');
throw new Error('Failed to get best sale prices for all users.');
}
}
@@ -164,12 +164,12 @@ export class PersonalizationRepository {
* Retrieves the master list of all available kitchen appliances.
* @returns A promise that resolves to an array of Appliance objects.
*/
async getAppliances(): Promise<Appliance[]> {
async getAppliances(logger: Logger): Promise<Appliance[]> {
try {
const res = await this.db.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
return res.rows;
} catch (error) {
logger.error('Database error in getAppliances:', { error });
logger.error({ err: error }, 'Database error in getAppliances');
throw new Error('Failed to get appliances.');
}
}
@@ -178,12 +178,12 @@ export class PersonalizationRepository {
* Retrieves the master list of all available dietary restrictions.
* @returns A promise that resolves to an array of DietaryRestriction objects.
*/
async getDietaryRestrictions(): Promise<DietaryRestriction[]> {
async getDietaryRestrictions(logger: Logger): Promise<DietaryRestriction[]> {
try {
const res = await this.db.query<DietaryRestriction>('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
return res.rows;
} catch (error) {
logger.error('Database error in getDietaryRestrictions:', { error });
logger.error({ err: error }, 'Database error in getDietaryRestrictions');
throw new Error('Failed to get dietary restrictions.');
}
}
@@ -193,7 +193,7 @@ export class PersonalizationRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's selected DietaryRestriction objects.
*/
async getUserDietaryRestrictions(userId: string): Promise<DietaryRestriction[]> {
async getUserDietaryRestrictions(userId: string, logger: Logger): Promise<DietaryRestriction[]> {
try {
const query = `
SELECT dr.* FROM public.dietary_restrictions dr
@@ -203,7 +203,7 @@ export class PersonalizationRepository {
const res = await this.db.query<DietaryRestriction>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserDietaryRestrictions:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getUserDietaryRestrictions');
throw new Error('Failed to get user dietary restrictions.');
}
}
@@ -214,7 +214,7 @@ export class PersonalizationRepository {
* @param restrictionIds An array of IDs for the selected dietary restrictions.
* @returns A promise that resolves when the operation is complete.
*/
async setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise<void> {
async setUserDietaryRestrictions(userId: string, restrictionIds: number[], logger: Logger): Promise<void> {
try {
await withTransaction(async (client) => {
// 1. Clear existing restrictions for the user
@@ -234,7 +234,7 @@ export class PersonalizationRepository {
if ((error as any).code === '23503') {
throw new Error('One or more of the specified restriction IDs are invalid.');
}
logger.error('Database error in setUserDietaryRestrictions:', { error, userId });
logger.error({ err: error, userId, restrictionIds }, 'Database error in setUserDietaryRestrictions');
throw new Error('Failed to set user dietary restrictions.');
}
}
@@ -245,7 +245,7 @@ export class PersonalizationRepository {
* @param applianceIds An array of IDs for the selected appliances.
* @returns A promise that resolves when the operation is complete.
*/
async setUserAppliances(userId: string, applianceIds: number[]): Promise<UserAppliance[]> {
async setUserAppliances(userId: string, applianceIds: number[], logger: Logger): Promise<UserAppliance[]> {
try {
return await withTransaction(async (client) => {
// 1. Clear existing appliances for the user
@@ -265,7 +265,7 @@ export class PersonalizationRepository {
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('Invalid appliance ID');
}
logger.error('Database error in setUserAppliances:', { error, userId });
logger.error({ err: error, userId, applianceIds }, 'Database error in setUserAppliances');
throw new Error('Failed to set user appliances.');
}
}
@@ -275,7 +275,7 @@ export class PersonalizationRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's selected Appliance objects.
*/
async getUserAppliances(userId: string): Promise<Appliance[]> {
async getUserAppliances(userId: string, logger: Logger): Promise<Appliance[]> {
try {
const query = `
SELECT a.* FROM public.appliances a
@@ -285,7 +285,7 @@ export class PersonalizationRepository {
const res = await this.db.query<Appliance>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserAppliances:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getUserAppliances');
throw new Error('Failed to get user appliances.');
}
}
@@ -295,12 +295,12 @@ export class PersonalizationRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of recipes.
*/
async findRecipesFromPantry(userId: string): Promise<PantryRecipe[]> {
async findRecipesFromPantry(userId: string, logger: Logger): Promise<PantryRecipe[]> {
try {
const res = await this.db.query<PantryRecipe>('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesFromPantry:', { error, userId });
logger.error({ err: error, userId }, 'Database error in findRecipesFromPantry');
throw new Error('Failed to find recipes from pantry.');
}
}
@@ -311,12 +311,12 @@ export class PersonalizationRepository {
* @param limit The maximum number of recipes to recommend.
* @returns A promise that resolves to an array of recommended recipes.
*/
async recommendRecipesForUser(userId: string, limit: number): Promise<RecommendedRecipe[]> {
async recommendRecipesForUser(userId: string, limit: number, logger: Logger): Promise<RecommendedRecipe[]> {
try {
const res = await this.db.query<RecommendedRecipe>('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in recommendRecipesForUser:', { error, userId, limit });
logger.error({ err: error, userId, limit }, 'Database error in recommendRecipesForUser');
throw new Error('Failed to recommend recipes.');
}
}
@@ -326,12 +326,12 @@ export class PersonalizationRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the best deals.
*/
async getBestSalePricesForUser(userId: string): Promise<WatchedItemDeal[]> {
async getBestSalePricesForUser(userId: string, logger: Logger): Promise<WatchedItemDeal[]> {
try {
const res = await this.db.query<WatchedItemDeal>('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForUser:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getBestSalePricesForUser');
throw new Error('Failed to get best sale prices.');
}
}
@@ -341,12 +341,12 @@ export class PersonalizationRepository {
* @param pantryItemId The ID of the pantry item.
* @returns A promise that resolves to an array of suggested conversions.
*/
async suggestPantryItemConversions(pantryItemId: number): Promise<PantryItemConversion[]> {
async suggestPantryItemConversions(pantryItemId: number, logger: Logger): Promise<PantryItemConversion[]> {
try {
const res = await this.db.query<PantryItemConversion>('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
return res.rows;
} catch (error) {
logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId });
logger.error({ err: error, pantryItemId }, 'Database error in suggestPantryItemConversions');
throw new Error('Failed to suggest pantry item conversions.');
}
}
@@ -356,12 +356,12 @@ export class PersonalizationRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of compatible Recipe objects.
*/
async getRecipesForUserDiets(userId: string): Promise<Recipe[]> {
async getRecipesForUserDiets(userId: string, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]); // This is a standalone function, no change needed here.
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesForUserDiets:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getRecipesForUserDiets');
throw new Error('Failed to get recipes compatible with user diet.');
}
}

View File

@@ -19,6 +19,7 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
describe('Recipe DB Service', () => {
let recipeRepo: RecipeRepository;
@@ -32,20 +33,21 @@ describe('Recipe DB Service', () => {
describe('getRecipesBySalePercentage', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recipeRepo.getRecipesBySalePercentage(50);
await recipeRepo.getRecipesBySalePercentage(50, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [50]);
});
it('should return an empty array if no recipes are found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getRecipesBySalePercentage(50);
const result = await recipeRepo.getRecipesBySalePercentage(50, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.getRecipesBySalePercentage(50)).rejects.toThrow('Failed to get recipes by sale percentage.');
await expect(recipeRepo.getRecipesBySalePercentage(50, mockLogger)).rejects.toThrow('Failed to get recipes by sale percentage.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, minPercentage: 50 }, 'Database error in getRecipesBySalePercentage');
});
});
@@ -53,20 +55,21 @@ describe('Recipe DB Service', () => {
describe('getRecipesByMinSaleIngredients', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recipeRepo.getRecipesByMinSaleIngredients(3);
await recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [3]);
});
it('should return an empty array if no recipes are found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getRecipesByMinSaleIngredients(3);
const result = await recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.getRecipesByMinSaleIngredients(3)).rejects.toThrow('Failed to get recipes by minimum sale ingredients.');
await expect(recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger)).rejects.toThrow('Failed to get recipes by minimum sale ingredients.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, minIngredients: 3 }, 'Database error in getRecipesByMinSaleIngredients');
});
});
@@ -74,20 +77,21 @@ describe('Recipe DB Service', () => {
describe('findRecipesByIngredientAndTag', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick');
await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', ['chicken', 'quick']);
});
it('should return an empty array if no recipes are found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick');
const result = await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick')).rejects.toThrow('Failed to find recipes by ingredient and tag.');
await expect(recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger)).rejects.toThrow('Failed to find recipes by ingredient and tag.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, ingredient: 'chicken', tag: 'quick' }, 'Database error in findRecipesByIngredientAndTag');
});
});
@@ -95,20 +99,21 @@ describe('Recipe DB Service', () => {
describe('getUserFavoriteRecipes', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recipeRepo.getUserFavoriteRecipes('user-123');
await recipeRepo.getUserFavoriteRecipes('user-123', mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', ['user-123']);
});
it('should return an empty array if user has no favorites', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getUserFavoriteRecipes('user-123');
const result = await recipeRepo.getUserFavoriteRecipes('user-123', mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.getUserFavoriteRecipes('user-123')).rejects.toThrow('Failed to get favorite recipes.');
await expect(recipeRepo.getUserFavoriteRecipes('user-123', mockLogger)).rejects.toThrow('Failed to get favorite recipes.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserFavoriteRecipes');
});
});
@@ -117,7 +122,7 @@ describe('Recipe DB Service', () => {
const mockFavorite: FavoriteRecipe = { user_id: 'user-123', recipe_id: 1, created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockFavorite] });
const result = await recipeRepo.addFavoriteRecipe('user-123', 1);
const result = await recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.favorite_recipes'), ['user-123', 1]);
expect(result).toEqual(mockFavorite);
@@ -127,64 +132,68 @@ describe('Recipe DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.addFavoriteRecipe('user-123', 999)).rejects.toThrow('The specified user or recipe does not exist.');
await expect(recipeRepo.addFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow('The specified user or recipe does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', recipeId: 999 }, 'Database error in addFavoriteRecipe');
});
it('should return undefined if the favorite already exists (ON CONFLICT)', async () => {
// When ON CONFLICT DO NOTHING happens, the RETURNING clause does not execute, so rows is empty.
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.addFavoriteRecipe('user-123', 1);
const result = await recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.addFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to add favorite recipe.');
await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow('Failed to add favorite recipe.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', recipeId: 1 }, 'Database error in addFavoriteRecipe');
});
});
describe('removeFavoriteRecipe', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rowCount: 1 });
await recipeRepo.removeFavoriteRecipe('user-123', 1);
await recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]);
});
it('should throw an error if the favorite recipe is not found', async () => {
// Simulate the DB returning 0 rows affected
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(recipeRepo.removeFavoriteRecipe('user-123', 999)).rejects.toThrow('Favorite recipe not found for this user.');
await expect(recipeRepo.removeFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow('Favorite recipe not found for this user.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.removeFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to remove favorite recipe.');
await expect(recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow('Failed to remove favorite recipe.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', recipeId: 1 }, 'Database error in removeFavoriteRecipe');
});
});
describe('deleteRecipe', () => {
it('should execute a DELETE query with user ownership check', async () => {
mockQuery.mockResolvedValue({ rowCount: 1 });
await recipeRepo.deleteRecipe(1, 'user-123', false);
await recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2', [1, 'user-123']);
});
it('should execute a DELETE query without user ownership check if isAdmin is true', async () => {
mockQuery.mockResolvedValue({ rowCount: 1 });
await recipeRepo.deleteRecipe(1, 'admin-user', true);
await recipeRepo.deleteRecipe(1, 'admin-user', true, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.recipes WHERE recipe_id = $1', [1]);
});
it('should throw an error if the recipe is not found or not owned by the user', async () => {
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(recipeRepo.deleteRecipe(999, 'user-123', false)).rejects.toThrow('Recipe not found or user does not have permission to delete.');
await expect(recipeRepo.deleteRecipe(999, 'user-123', false, mockLogger)).rejects.toThrow('Recipe not found or user does not have permission to delete.');
});
it('should throw a generic error if the database query fails', async () => {
mockQuery.mockRejectedValue(new Error('DB Error'));
await expect(recipeRepo.deleteRecipe(1, 'user-123', false)).rejects.toThrow('Failed to delete recipe.');
await expect(recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger)).rejects.toThrow('Failed to delete recipe.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), recipeId: 1, userId: 'user-123', isAdmin: false }, 'Database error in deleteRecipe');
});
});
@@ -194,7 +203,7 @@ describe('Recipe DB Service', () => {
mockQuery.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
const updates = { name: 'Updated Recipe', description: 'New desc' };
const result = await recipeRepo.updateRecipe(1, 'user-123', updates);
const result = await recipeRepo.updateRecipe(1, 'user-123', updates, mockLogger);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.recipes'),
@@ -204,17 +213,18 @@ describe('Recipe DB Service', () => {
});
it('should throw an error if no fields are provided to update', async () => {
await expect(recipeRepo.updateRecipe(1, 'user-123', {})).rejects.toThrow('No fields provided to update.');
await expect(recipeRepo.updateRecipe(1, 'user-123', {}, mockLogger)).rejects.toThrow('No fields provided to update.');
});
it('should throw an error if the recipe is not found or not owned by the user', async () => {
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(recipeRepo.updateRecipe(999, 'user-123', { name: 'Fail' })).rejects.toThrow('Recipe not found or user does not have permission to update.');
await expect(recipeRepo.updateRecipe(999, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Recipe not found or user does not have permission to update.');
});
it('should throw a generic error if the database query fails', async () => {
mockQuery.mockRejectedValue(new Error('DB Error'));
await expect(recipeRepo.updateRecipe(1, 'user-123', { name: 'Fail' })).rejects.toThrow('Failed to update recipe.');
await expect(recipeRepo.updateRecipe(1, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Failed to update recipe.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), recipeId: 1, userId: 'user-123', updates: { name: 'Fail' } }, 'Database error in updateRecipe');
});
});
@@ -222,40 +232,42 @@ describe('Recipe DB Service', () => {
it('should execute a SELECT query and return the recipe', async () => {
const mockRecipe: Recipe = { recipe_id: 1, name: 'Test Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: '' };
mockQuery.mockResolvedValue({ rows: [mockRecipe] });
const result = await recipeRepo.getRecipeById(1);
const result = await recipeRepo.getRecipeById(1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipes r'), [1]);
expect(result).toEqual(mockRecipe);
});
it('should throw NotFoundError if recipe is not found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await expect(recipeRepo.getRecipeById(999)).rejects.toThrow('Recipe with ID 999 not found');
await expect(recipeRepo.getRecipeById(999, mockLogger)).rejects.toThrow('Recipe with ID 999 not found');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.getRecipeById(1)).rejects.toThrow('Failed to retrieve recipe.');
await expect(recipeRepo.getRecipeById(1, mockLogger)).rejects.toThrow('Failed to retrieve recipe.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1 }, 'Database error in getRecipeById');
});
});
describe('getRecipeComments', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recipeRepo.getRecipeComments(1);
await recipeRepo.getRecipeComments(1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipe_comments rc'), [1]);
});
it('should return an empty array if recipe has no comments', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getRecipeComments(1);
const result = await recipeRepo.getRecipeComments(1, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.getRecipeComments(1)).rejects.toThrow('Failed to get recipe comments.');
await expect(recipeRepo.getRecipeComments(1, mockLogger)).rejects.toThrow('Failed to get recipe comments.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1 }, 'Database error in getRecipeComments');
});
});
@@ -264,7 +276,7 @@ describe('Recipe DB Service', () => {
const mockComment: RecipeComment = { recipe_comment_id: 1, recipe_id: 1, user_id: 'user-123', content: 'Great!', status: 'visible', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockComment] });
const result = await recipeRepo.addRecipeComment(1, 'user-123', 'Great!');
const result = await recipeRepo.addRecipeComment(1, 'user-123', 'Great!', mockLogger);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.recipe_comments'), [1, 'user-123', 'Great!', undefined]);
expect(result).toEqual(mockComment);
@@ -274,13 +286,15 @@ describe('Recipe DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.addRecipeComment(999, 'user-123', 'Fail')).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
await expect(recipeRepo.addRecipeComment(999, 'user-123', 'Fail', mockLogger)).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 999, userId: 'user-123', parentCommentId: undefined }, 'Database error in addRecipeComment');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.addRecipeComment(1, 'user-123', 'Fail')).rejects.toThrow('Failed to add recipe comment.');
await expect(recipeRepo.addRecipeComment(1, 'user-123', 'Fail', mockLogger)).rejects.toThrow('Failed to add recipe comment.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1, userId: 'user-123', parentCommentId: undefined }, 'Database error in addRecipeComment');
});
});
@@ -289,7 +303,7 @@ describe('Recipe DB Service', () => {
const mockRecipe: Recipe = { recipe_id: 2, name: 'Forked Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'private', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockRecipe] });
const result = await recipeRepo.forkRecipe('user-123', 1);
const result = await recipeRepo.forkRecipe('user-123', 1, mockLogger);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', ['user-123', 1]);
expect(result).toEqual(mockRecipe);
});
@@ -299,13 +313,15 @@ describe('Recipe DB Service', () => {
(dbError as any).code = 'P0001'; // raise_exception
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.forkRecipe('user-123', 1)).rejects.toThrow('Recipe is not public and cannot be forked.');
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow('Recipe is not public and cannot be forked.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', originalRecipeId: 1 }, 'Database error in forkRecipe');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.forkRecipe('user-123', 1)).rejects.toThrow('Failed to fork recipe.');
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow('Failed to fork recipe.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', originalRecipeId: 1 }, 'Database error in forkRecipe');
});
});
});

View File

@@ -2,8 +2,8 @@
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
import type { Logger } from 'pino';
import type { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
export class RecipeRepository {
private db: Pool | PoolClient;
@@ -17,12 +17,12 @@ export class RecipeRepository {
* @param minPercentage The minimum percentage of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
async getRecipesBySalePercentage(minPercentage: number): Promise<Recipe[]> {
async getRecipesBySalePercentage(minPercentage: number, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesBySalePercentage:', { error });
logger.error({ err: error, minPercentage }, 'Database error in getRecipesBySalePercentage');
throw new Error('Failed to get recipes by sale percentage.');
}
}
@@ -32,12 +32,12 @@ export class RecipeRepository {
* @param minIngredients The minimum number of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
async getRecipesByMinSaleIngredients(minIngredients: number): Promise<Recipe[]> {
async getRecipesByMinSaleIngredients(minIngredients: number, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesByMinSaleIngredients:', { error });
logger.error({ err: error, minIngredients }, 'Database error in getRecipesByMinSaleIngredients');
throw new Error('Failed to get recipes by minimum sale ingredients.');
}
}
@@ -48,12 +48,12 @@ export class RecipeRepository {
* @param tag The name of the tag to search for.
* @returns A promise that resolves to an array of matching recipes.
*/
async findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<Recipe[]> {
async findRecipesByIngredientAndTag(ingredient: string, tag: string, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesByIngredientAndTag:', { error });
logger.error({ err: error, ingredient, tag }, 'Database error in findRecipesByIngredientAndTag');
throw new Error('Failed to find recipes by ingredient and tag.');
}
}
@@ -63,12 +63,12 @@ export class RecipeRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's favorite recipes.
*/
async getUserFavoriteRecipes(userId: string): Promise<Recipe[]> {
async getUserFavoriteRecipes(userId: string, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_user_favorite_recipes($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserFavoriteRecipes:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getUserFavoriteRecipes');
throw new Error('Failed to get favorite recipes.');
}
}
@@ -79,7 +79,7 @@ export class RecipeRepository {
* @param recipeId The ID of the recipe to favorite.
* @returns A promise that resolves to the created favorite record.
*/
async addFavoriteRecipe(userId: string, recipeId: number): Promise<FavoriteRecipe> {
async addFavoriteRecipe(userId: string, recipeId: number, logger: Logger): Promise<FavoriteRecipe> {
try {
const res = await this.db.query<FavoriteRecipe>(
'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *',
@@ -90,7 +90,7 @@ export class RecipeRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or recipe does not exist.');
}
logger.error('Database error in addFavoriteRecipe:', { error, userId, recipeId });
logger.error({ err: error, userId, recipeId }, 'Database error in addFavoriteRecipe');
throw new Error('Failed to add favorite recipe.');
}
}
@@ -100,7 +100,7 @@ export class RecipeRepository {
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to unfavorite.
*/
async removeFavoriteRecipe(userId: string, recipeId: number): Promise<void> {
async removeFavoriteRecipe(userId: string, recipeId: number, logger: Logger): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
if (res.rowCount === 0) {
@@ -110,7 +110,7 @@ export class RecipeRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId });
logger.error({ err: error, userId, recipeId }, 'Database error in removeFavoriteRecipe');
throw new Error('Failed to remove favorite recipe.');
}
}
@@ -121,7 +121,7 @@ export class RecipeRepository {
* @param userId The ID of the user attempting to delete the recipe.
* @param isAdmin A boolean indicating if the user is an administrator.
*/
async deleteRecipe(recipeId: number, userId: string, isAdmin: boolean): Promise<void> {
async deleteRecipe(recipeId: number, userId: string, isAdmin: boolean, logger: Logger): Promise<void> {
try {
let query = 'DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2';
const params = [recipeId, userId];
@@ -137,6 +137,7 @@ export class RecipeRepository {
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error({ err: error, recipeId, userId, isAdmin }, 'Database error in deleteRecipe');
throw new Error('Failed to delete recipe.');
}
}
@@ -148,7 +149,7 @@ export class RecipeRepository {
* @param updates An object containing the fields to update.
* @returns A promise that resolves to the updated Recipe object.
*/
async updateRecipe(recipeId: number, userId: string, updates: Partial<Pick<Recipe, 'name' | 'description' | 'instructions' | 'prep_time_minutes' | 'cook_time_minutes' | 'servings' | 'photo_url'>>): Promise<Recipe> {
async updateRecipe(recipeId: number, userId: string, updates: Partial<Pick<Recipe, 'name' | 'description' | 'instructions' | 'prep_time_minutes' | 'cook_time_minutes' | 'servings' | 'photo_url'>>, logger: Logger): Promise<Recipe> {
try {
const setClauses = [];
const values = [];
@@ -184,7 +185,7 @@ export class RecipeRepository {
if (error instanceof NotFoundError || (error instanceof Error && error.message.includes('No fields provided'))) {
throw error;
}
logger.error('Database error in updateRecipe:', { error, recipeId, userId });
logger.error({ err: error, recipeId, userId, updates }, 'Database error in updateRecipe');
throw new Error('Failed to update recipe.');
}
}
@@ -194,7 +195,7 @@ export class RecipeRepository {
* @param recipeId The ID of the recipe to retrieve.
* @returns A promise that resolves to the Recipe object or undefined if not found.
*/
async getRecipeById(recipeId: number): Promise<Recipe> {
async getRecipeById(recipeId: number, logger: Logger): Promise<Recipe> {
try {
const query = `
SELECT
@@ -218,7 +219,7 @@ export class RecipeRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in getRecipeById:', { error, recipeId });
logger.error({ err: error, recipeId }, 'Database error in getRecipeById');
throw new Error('Failed to retrieve recipe.');
}
}
@@ -228,7 +229,7 @@ export class RecipeRepository {
* @param recipeId The ID of the recipe.
* @returns A promise that resolves to an array of RecipeComment objects.
*/
async getRecipeComments(recipeId: number): Promise<RecipeComment[]> {
async getRecipeComments(recipeId: number, logger: Logger): Promise<RecipeComment[]> {
try {
const query = `
SELECT
@@ -243,7 +244,7 @@ export class RecipeRepository {
const res = await this.db.query<RecipeComment>(query, [recipeId]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipeComments:', { error, recipeId });
logger.error({ err: error, recipeId }, 'Database error in getRecipeComments');
throw new Error('Failed to get recipe comments.');
}
}
@@ -256,7 +257,7 @@ export class RecipeRepository {
* @param parentCommentId Optional ID of the parent comment for threaded replies.
* @returns A promise that resolves to the newly created RecipeComment object.
*/
async addRecipeComment(recipeId: number, userId: string, content: string, parentCommentId?: number): Promise<RecipeComment> {
async addRecipeComment(recipeId: number, userId: string, content: string, logger: Logger, parentCommentId?: number): Promise<RecipeComment> {
try {
const res = await this.db.query<RecipeComment>(
'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *',
@@ -268,7 +269,7 @@ export class RecipeRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') { // foreign_key_violation
throw new ForeignKeyConstraintError('The specified recipe, user, or parent comment does not exist.');
}
logger.error('Database error in addRecipeComment:', { error });
logger.error({ err: error, recipeId, userId, parentCommentId }, 'Database error in addRecipeComment');
throw new Error('Failed to add recipe comment.');
}
}
@@ -279,7 +280,7 @@ export class RecipeRepository {
* @param originalRecipeId The ID of the recipe to fork.
* @returns A promise that resolves to the newly created forked Recipe object.
*/
async forkRecipe(userId: string, originalRecipeId: number): Promise<Recipe> {
async forkRecipe(userId: string, originalRecipeId: number, logger: Logger): Promise<Recipe> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [userId, originalRecipeId]);
return res.rows[0];
@@ -288,7 +289,7 @@ export class RecipeRepository {
if (error instanceof Error && 'code' in error && error.code === 'P0001') { // raise_exception
throw new Error(error.message); // Re-throw the user-friendly message from the DB function.
}
logger.error('Database error in forkRecipe:', { error, userId, originalRecipeId });
logger.error({ err: error, userId, originalRecipeId }, 'Database error in forkRecipe');
throw new Error('Failed to fork recipe.');
}
}

View File

@@ -19,6 +19,7 @@ vi.mock('../logger.server', () => ({
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
@@ -40,7 +41,7 @@ describe('Shopping DB Service', () => {
const mockLists = [createMockShoppingList({ user_id: 'user-1' })];
mockPoolInstance.query.mockResolvedValue({ rows: mockLists });
const result = await shoppingRepo.getShoppingLists('user-1');
const result = await shoppingRepo.getShoppingLists('user-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-1']);
expect(result).toEqual(mockLists);
@@ -48,14 +49,16 @@ describe('Shopping DB Service', () => {
it('should return an empty array if a user has no shopping lists', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.getShoppingLists('user-with-no-lists');
const result = await shoppingRepo.getShoppingLists('user-with-no-lists', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-with-no-lists']);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
await expect(shoppingRepo.getShoppingLists('user-1')).rejects.toThrow('Failed to retrieve shopping lists.');
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.getShoppingLists('user-1', mockLogger)).rejects.toThrow('Failed to retrieve shopping lists.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1' }, 'Database error in getShoppingLists');
});
});
@@ -64,7 +67,7 @@ describe('Shopping DB Service', () => {
const mockList = createMockShoppingList({ shopping_list_id: 1, user_id: 'user-1' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockList] });
const result = await shoppingRepo.getShoppingListById(1, 'user-1');
const result = await shoppingRepo.getShoppingListById(1, 'user-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE sl.shopping_list_id = $1 AND sl.user_id = $2'),
@@ -76,14 +79,15 @@ describe('Shopping DB Service', () => {
it('should throw NotFoundError if the shopping list is not found or not owned by the user', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(shoppingRepo.getShoppingListById(999, 'user-1')).rejects.toThrow('Shopping list not found or you do not have permission to view it.');
await expect(shoppingRepo.getShoppingListById(999, 'user-1', mockLogger)).rejects.toThrow('Shopping list not found or you do not have permission to view it.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.getShoppingListById(1, 'user-1')).rejects.toThrow('Failed to retrieve shopping list.');
await expect(shoppingRepo.getShoppingListById(1, 'user-1', mockLogger)).rejects.toThrow('Failed to retrieve shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 1, userId: 'user-1' }, 'Database error in getShoppingListById');
});
});
@@ -92,7 +96,7 @@ describe('Shopping DB Service', () => {
const mockList = createMockShoppingList({ name: 'New List' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockList] });
const result = await shoppingRepo.createShoppingList('user-1', 'New List');
const result = await shoppingRepo.createShoppingList('user-1', 'New List', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_lists'), ['user-1', 'New List']);
expect(result).toEqual(mockList);
@@ -102,32 +106,36 @@ describe('Shopping DB Service', () => {
const dbError = new Error('insert or update on table "shopping_lists" violates foreign key constraint "shopping_lists_user_id_fkey"');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createShoppingList('non-existent-user', 'Wont work')).rejects.toThrow('The specified user does not exist.');
await expect(shoppingRepo.createShoppingList('non-existent-user', 'Wont work', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', name: 'Wont work' }, 'Database error in createShoppingList');
});
it('should throw a generic error if the database query fails for other reasons', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createShoppingList('user-1', 'New List')).rejects.toThrow('Failed to create shopping list.');
await expect(shoppingRepo.createShoppingList('user-1', 'New List', mockLogger)).rejects.toThrow('Failed to create shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', name: 'New List' }, 'Database error in createShoppingList');
});
});
describe('deleteShoppingList', () => {
it('should delete a shopping list if rowCount is 1', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
await expect(shoppingRepo.deleteShoppingList(1, 'user-1')).resolves.toBeUndefined();
await expect(shoppingRepo.deleteShoppingList(1, 'user-1', mockLogger)).resolves.toBeUndefined();
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [1, 'user-1']);
});
it('should throw an error if no rows are deleted (list not found or wrong user)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
await expect(shoppingRepo.deleteShoppingList(999, 'user-1')).rejects.toThrow('Failed to delete shopping list.');
await expect(shoppingRepo.deleteShoppingList(999, 'user-1', mockLogger)).rejects.toThrow(NotFoundError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(NotFoundError), listId: 999, userId: 'user-1' }, 'Database error in deleteShoppingList');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.deleteShoppingList(1, 'user-1')).rejects.toThrow('Failed to delete shopping list.');
await expect(shoppingRepo.deleteShoppingList(1, 'user-1', mockLogger)).rejects.toThrow('Failed to delete shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 1, userId: 'user-1' }, 'Database error in deleteShoppingList');
});
});
@@ -136,7 +144,7 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ custom_item_name: 'Custom Item' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
const result = await shoppingRepo.addShoppingListItem(1, { customItemName: 'Custom Item' });
const result = await shoppingRepo.addShoppingListItem(1, { customItemName: 'Custom Item' }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, null, 'Custom Item']);
expect(result).toEqual(mockItem);
@@ -146,7 +154,7 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ master_item_id: 123 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123 });
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123 }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, 123, null]);
expect(result).toEqual(mockItem);
@@ -156,27 +164,29 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ master_item_id: 123, custom_item_name: 'Organic Apples' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123, customItemName: 'Organic Apples' });
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123, customItemName: 'Organic Apples' }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, 123, 'Organic Apples']);
expect(result).toEqual(mockItem);
});
it('should throw an error if both masterItemId and customItemName are missing', async () => {
await expect(shoppingRepo.addShoppingListItem(1, {})).rejects.toThrow('Either masterItemId or customItemName must be provided.');
await expect(shoppingRepo.addShoppingListItem(1, {}, mockLogger)).rejects.toThrow('Either masterItemId or customItemName must be provided.');
});
it('should throw ForeignKeyConstraintError if list or master item does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.addShoppingListItem(999, { masterItemId: 999 })).rejects.toThrow(ForeignKeyConstraintError);
await expect(shoppingRepo.addShoppingListItem(999, { masterItemId: 999 }, mockLogger)).rejects.toThrow('Referenced list or item does not exist.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 999, item: { masterItemId: 999 } }, 'Database error in addShoppingListItem');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.addShoppingListItem(1, { customItemName: 'Test' })).rejects.toThrow('Failed to add item to shopping list.');
await expect(shoppingRepo.addShoppingListItem(1, { customItemName: 'Test' }, mockLogger)).rejects.toThrow('Failed to add item to shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 1, item: { customItemName: 'Test' } }, 'Database error in addShoppingListItem');
});
});
@@ -185,7 +195,7 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ shopping_list_item_id: 1, is_purchased: true });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
const result = await shoppingRepo.updateShoppingListItem(1, { is_purchased: true });
const result = await shoppingRepo.updateShoppingListItem(1, { is_purchased: true }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'UPDATE public.shopping_list_items SET is_purchased = $1 WHERE shopping_list_item_id = $2 RETURNING *',
@@ -199,7 +209,7 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ shopping_list_item_id: 1, ...updates });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
const result = await shoppingRepo.updateShoppingListItem(1, updates);
const result = await shoppingRepo.updateShoppingListItem(1, updates, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'UPDATE public.shopping_list_items SET quantity = $1, is_purchased = $2, notes = $3 WHERE shopping_list_item_id = $4 RETURNING *',
@@ -210,44 +220,46 @@ describe('Shopping DB Service', () => {
it('should throw an error if the item to update is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'UPDATE' });
await expect(shoppingRepo.updateShoppingListItem(999, { quantity: 5 })).rejects.toThrow('Shopping list item not found.');
await expect(shoppingRepo.updateShoppingListItem(999, { quantity: 5 }, mockLogger)).rejects.toThrow('Shopping list item not found.');
});
it('should throw an error if no valid fields are provided to update', async () => {
// The function should throw before even querying the database.
await expect(shoppingRepo.updateShoppingListItem(1, {})).rejects.toThrow('No valid fields to update.');
await expect(shoppingRepo.updateShoppingListItem(1, {}, mockLogger)).rejects.toThrow('No valid fields to update.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.updateShoppingListItem(1, { is_purchased: true })).rejects.toThrow('Failed to update shopping list item.');
await expect(shoppingRepo.updateShoppingListItem(1, { is_purchased: true }, mockLogger)).rejects.toThrow('Failed to update shopping list item.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, itemId: 1, updates: { is_purchased: true } }, 'Database error in updateShoppingListItem');
});
});
describe('removeShoppingListItem', () => {
it('should delete an item if rowCount is 1', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
await expect(shoppingRepo.removeShoppingListItem(1)).resolves.toBeUndefined();
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).resolves.toBeUndefined();
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [1]);
});
it('should throw an error if no rows are deleted (item not found)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
await expect(shoppingRepo.removeShoppingListItem(999)).rejects.toThrow('Shopping list item not found.');
await expect(shoppingRepo.removeShoppingListItem(999, mockLogger)).rejects.toThrow('Shopping list item not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.removeShoppingListItem(1)).rejects.toThrow('Failed to remove item from shopping list.');
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).rejects.toThrow('Failed to remove item from shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, itemId: 1 }, 'Database error in removeShoppingListItem');
});
});
describe('completeShoppingList', () => {
it('should call the complete_shopping_list database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ complete_shopping_list: 1 }] });
const result = await shoppingRepo.completeShoppingList(1, 'user-123', 5000);
const result = await shoppingRepo.completeShoppingList(1, 'user-123', mockLogger, 5000);
expect(result).toBe(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.complete_shopping_list($1, $2, $3)', [1, 'user-123', 5000]);
});
@@ -256,13 +268,15 @@ describe('Shopping DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.completeShoppingList(999, 'user-123')).rejects.toThrow('The specified shopping list does not exist.');
await expect(shoppingRepo.completeShoppingList(999, 'user-123', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, shoppingListId: 999, userId: 'user-123' }, 'Database error in completeShoppingList');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Function Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.completeShoppingList(1, 'user-123')).rejects.toThrow('Failed to complete shopping list.');
await expect(shoppingRepo.completeShoppingList(1, 'user-123', mockLogger)).rejects.toThrow('Failed to complete shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, shoppingListId: 1, userId: 'user-123' }, 'Database error in completeShoppingList');
});
});
@@ -270,20 +284,22 @@ describe('Shopping DB Service', () => {
it('should call the correct database function and return items', async () => {
const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1');
const result = await shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [1, 'user-1']);
expect(result).toEqual(mockItems);
});
it('should return an empty array if the menu plan generates no items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1');
const result = await shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1')).rejects.toThrow('Failed to generate shopping list for menu plan.');
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1', mockLogger)).rejects.toThrow('Failed to generate shopping list for menu plan.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, menuPlanId: 1, userId: 'user-1' }, 'Database error in generateShoppingListForMenuPlan');
});
});
@@ -291,20 +307,22 @@ describe('Shopping DB Service', () => {
it('should call the correct database function and return added items', async () => {
const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1');
const result = await shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [1, 10, 'user-1']);
expect(result).toEqual(mockItems);
});
it('should return an empty array if no items are added from the menu plan', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1');
const result = await shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1')).rejects.toThrow('Failed to add menu plan to shopping list.');
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1', mockLogger)).rejects.toThrow('Failed to add menu plan to shopping list.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, menuPlanId: 1, shoppingListId: 10, userId: 'user-1' }, 'Database error in addMenuPlanToShoppingList');
});
});
@@ -312,20 +330,22 @@ describe('Shopping DB Service', () => {
it('should return a list of pantry locations for a user', async () => {
const mockLocations = [{ pantry_location_id: 1, name: 'Fridge', user_id: 'user-1' }];
mockPoolInstance.query.mockResolvedValue({ rows: mockLocations });
const result = await shoppingRepo.getPantryLocations('user-1');
const result = await shoppingRepo.getPantryLocations('user-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', ['user-1']);
expect(result).toEqual(mockLocations);
});
it('should return an empty array if user has no pantry locations', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.getPantryLocations('user-1');
const result = await shoppingRepo.getPantryLocations('user-1', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(shoppingRepo.getPantryLocations('user-1')).rejects.toThrow('Failed to get pantry locations.');
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.getPantryLocations('user-1', mockLogger)).rejects.toThrow('Failed to get pantry locations.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1' }, 'Database error in getPantryLocations');
});
});
@@ -333,7 +353,7 @@ describe('Shopping DB Service', () => {
it('should insert a new pantry location and return it', async () => {
const mockLocation = { pantry_location_id: 1, name: 'Freezer', user_id: 'user-1' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockLocation] });
const result = await shoppingRepo.createPantryLocation('user-1', 'Freezer');
const result = await shoppingRepo.createPantryLocation('user-1', 'Freezer', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *', ['user-1', 'Freezer']);
expect(result).toEqual(mockLocation);
});
@@ -342,19 +362,23 @@ describe('Shopping DB Service', () => {
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createPantryLocation('user-1', 'Fridge')).rejects.toThrow(UniqueConstraintError);
await expect(shoppingRepo.createPantryLocation('user-1', 'Fridge', mockLogger)).rejects.toThrow('A pantry location with this name already exists.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', name: 'Fridge' }, 'Database error in createPantryLocation');
});
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createPantryLocation('non-existent-user', 'Pantry')).rejects.toThrow(ForeignKeyConstraintError);
await expect(shoppingRepo.createPantryLocation('non-existent-user', 'Pantry', mockLogger)).rejects.toThrow('User not found');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', name: 'Pantry' }, 'Database error in createPantryLocation');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(shoppingRepo.createPantryLocation('user-1', 'Pantry')).rejects.toThrow('Failed to create pantry location.');
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createPantryLocation('user-1', 'Pantry', mockLogger)).rejects.toThrow('Failed to create pantry location.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', name: 'Pantry' }, 'Database error in createPantryLocation');
});
});
@@ -362,20 +386,22 @@ describe('Shopping DB Service', () => {
it('should return a list of shopping trips for a user', async () => {
const mockTrips = [{ shopping_trip_id: 1, user_id: 'user-1', items: [] }];
mockPoolInstance.query.mockResolvedValue({ rows: mockTrips });
const result = await shoppingRepo.getShoppingTripHistory('user-1');
const result = await shoppingRepo.getShoppingTripHistory('user-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_trips st'), ['user-1']);
expect(result).toEqual(mockTrips);
});
it('should return an empty array if a user has no shopping trips', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.getShoppingTripHistory('user-no-trips');
const result = await shoppingRepo.getShoppingTripHistory('user-no-trips', mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(shoppingRepo.getShoppingTripHistory('user-1')).rejects.toThrow('Failed to retrieve shopping trip history.');
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.getShoppingTripHistory('user-1', mockLogger)).rejects.toThrow('Failed to retrieve shopping trip history.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1' }, 'Database error in getShoppingTripHistory');
});
});
@@ -383,7 +409,7 @@ describe('Shopping DB Service', () => {
it('should insert a new receipt and return it', async () => {
const mockReceipt = { receipt_id: 1, user_id: 'user-1', receipt_image_url: 'url', status: 'pending' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });
const result = await shoppingRepo.createReceipt('user-1', 'url');
const result = await shoppingRepo.createReceipt('user-1', 'url', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.receipts'), ['user-1', 'url']);
expect(result).toEqual(mockReceipt);
});
@@ -392,12 +418,15 @@ describe('Shopping DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createReceipt('non-existent-user', 'url')).rejects.toThrow(ForeignKeyConstraintError);
await expect(shoppingRepo.createReceipt('non-existent-user', 'url', mockLogger)).rejects.toThrow('User not found');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', receiptImageUrl: 'url' }, 'Database error in createReceipt');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(shoppingRepo.createReceipt('user-1', 'url')).rejects.toThrow('Failed to create receipt record.');
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createReceipt('user-1', 'url', mockLogger)).rejects.toThrow('Failed to create receipt record.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', receiptImageUrl: 'url' }, 'Database error in createReceipt');
});
});
@@ -405,21 +434,22 @@ describe('Shopping DB Service', () => {
it('should return the user_id of the receipt owner', async () => {
const mockOwner = { user_id: 'owner-123' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockOwner] });
const result = await shoppingRepo.findReceiptOwner(1);
const result = await shoppingRepo.findReceiptOwner(1, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT user_id FROM public.receipts WHERE receipt_id = $1', [1]);
expect(result).toEqual(mockOwner);
});
it('should return undefined if the receipt is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.findReceiptOwner(999);
const result = await shoppingRepo.findReceiptOwner(999, mockLogger);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.findReceiptOwner(1)).rejects.toThrow('Failed to retrieve receipt owner from database.');
await expect(shoppingRepo.findReceiptOwner(1, mockLogger)).rejects.toThrow('Failed to retrieve receipt owner from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database error in findReceiptOwner');
});
});
@@ -432,7 +462,7 @@ describe('Shopping DB Service', () => {
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await shoppingRepo.processReceiptItems(1, items);
await shoppingRepo.processReceiptItems(1, items, mockLogger);
const expectedItemsWithQuantity = [{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 }];
expect(withTransaction).toHaveBeenCalledTimes(1);
@@ -452,8 +482,9 @@ describe('Shopping DB Service', () => {
});
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await expect(shoppingRepo.processReceiptItems(1, items)).rejects.toThrow('Failed to process and save receipt items.');
await expect(shoppingRepo.processReceiptItems(1, items, mockLogger)).rejects.toThrow('Failed to process and save receipt items.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database transaction error in processReceiptItems');
// Verify that the status was updated to 'failed' in the catch block
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [1]);
});
@@ -464,21 +495,22 @@ describe('Shopping DB Service', () => {
const mockDeals = [{ receipt_item_id: 1, master_item_id: 10, item_name: 'Milk', price_paid_cents: 399, current_best_price_in_cents: 350, potential_savings_cents: 49, deal_store_name: 'Grocer', flyer_id: 101 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockDeals });
const result = await shoppingRepo.findDealsForReceipt(1);
const result = await shoppingRepo.findDealsForReceipt(1, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.find_deals_for_receipt_items($1)', [1]);
expect(result).toEqual(mockDeals);
});
it('should return an empty array if no deals are found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.findDealsForReceipt(1);
const result = await shoppingRepo.findDealsForReceipt(1, mockLogger);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.findDealsForReceipt(1)).rejects.toThrow('Failed to find deals for receipt.');
await expect(shoppingRepo.findDealsForReceipt(1, mockLogger)).rejects.toThrow('Failed to find deals for receipt.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database error in findDealsForReceipt');
});
});
});

View File

@@ -2,7 +2,7 @@
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, UniqueConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import {
ShoppingList,
ShoppingListItem,
@@ -26,7 +26,7 @@ export class ShoppingRepository {
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of ShoppingList objects.
*/
async getShoppingLists(userId: string): Promise<ShoppingList[]> {
async getShoppingLists(userId: string, logger: Logger): Promise<ShoppingList[]> {
try {
const query = `
SELECT
@@ -53,7 +53,7 @@ export class ShoppingRepository {
const res = await this.db.query<ShoppingList>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getShoppingLists:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getShoppingLists');
throw new Error('Failed to retrieve shopping lists.');
}
}
@@ -64,7 +64,7 @@ export class ShoppingRepository {
* @param name The name of the new shopping list.
* @returns A promise that resolves to the newly created ShoppingList object.
*/
async createShoppingList(userId: string, name: string): Promise<ShoppingList> {
async createShoppingList(userId: string, name: string, logger: Logger): Promise<ShoppingList> {
try {
const res = await this.db.query<ShoppingList>(
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id, user_id, name, created_at',
@@ -76,7 +76,7 @@ export class ShoppingRepository {
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createShoppingList:', { error });
logger.error({ err: error, userId, name }, 'Database error in createShoppingList');
// The patch requested this specific error handling.
throw new Error('Failed to create shopping list.');
}
@@ -88,7 +88,7 @@ export class ShoppingRepository {
* @param userId The ID of the user requesting the list.
* @returns A promise that resolves to the ShoppingList object or undefined if not found or not owned by the user.
*/
async getShoppingListById(listId: number, userId: string): Promise<ShoppingList> {
async getShoppingListById(listId: number, userId: string, logger: Logger): Promise<ShoppingList> {
try {
const query = `
SELECT
@@ -118,7 +118,7 @@ export class ShoppingRepository {
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in getShoppingListById:', { error, listId, userId });
logger.error({ err: error, listId, userId }, 'Database error in getShoppingListById');
throw new Error('Failed to retrieve shopping list.');
}
}
@@ -128,7 +128,7 @@ export class ShoppingRepository {
* @param listId The ID of the shopping list to delete.
* @param userId The ID of the user who owns the list, for an ownership check.
*/
async deleteShoppingList(listId: number, userId: string): Promise<void> {
async deleteShoppingList(listId: number, userId: string, logger: Logger): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
// The patch requested this specific error handling.
@@ -136,7 +136,7 @@ export class ShoppingRepository {
throw new NotFoundError('Shopping list not found or user does not have permission to delete.');
}
} catch (error) {
logger.error('Database error in deleteShoppingList:', { error, listId, userId });
logger.error({ err: error, listId, userId }, 'Database error in deleteShoppingList');
throw new Error('Failed to delete shopping list.');
}
}
@@ -147,7 +147,7 @@ export class ShoppingRepository {
* @param item An object containing either a `masterItemId` or a `customItemName`.
* @returns A promise that resolves to the newly created ShoppingListItem object.
*/
async addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
async addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }, logger: Logger): Promise<ShoppingListItem> {
// The patch requested this specific error handling.
if (!item.masterItemId && !item.customItemName) {
throw new Error('Either masterItemId or customItemName must be provided.');
@@ -164,7 +164,7 @@ export class ShoppingRepository {
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('Referenced list or item does not exist.');
}
logger.error('Database error in addShoppingListItem:', { error });
logger.error({ err: error, listId, item }, 'Database error in addShoppingListItem');
throw new Error('Failed to add item to shopping list.');
}
}
@@ -173,7 +173,7 @@ export class ShoppingRepository {
* Removes an item from a shopping list.
* @param itemId The ID of the shopping list item to remove.
*/
async removeShoppingListItem(itemId: number): Promise<void> {
async removeShoppingListItem(itemId: number, logger: Logger): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
// The patch requested this specific error handling.
@@ -182,7 +182,7 @@ export class ShoppingRepository {
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in removeShoppingListItem:', { error, itemId });
logger.error({ err: error, itemId }, 'Database error in removeShoppingListItem');
throw new Error('Failed to remove item from shopping list.');
}
}
@@ -192,12 +192,12 @@ export class ShoppingRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of items for the shopping list.
*/
async generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
async generateShoppingListForMenuPlan(menuPlanId: number, userId: string, logger: Logger): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
logger.error({ err: error, menuPlanId, userId }, 'Database error in generateShoppingListForMenuPlan');
throw new Error('Failed to generate shopping list for menu plan.');
}
}
@@ -209,12 +209,12 @@ export class ShoppingRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the items that were added.
*/
async addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
async addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string, logger: Logger): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
logger.error({ err: error, menuPlanId, shoppingListId, userId }, 'Database error in addMenuPlanToShoppingList');
throw new Error('Failed to add menu plan to shopping list.');
}
}
@@ -224,12 +224,12 @@ export class ShoppingRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of PantryLocation objects.
*/
async getPantryLocations(userId: string): Promise<PantryLocation[]> {
async getPantryLocations(userId: string, logger: Logger): Promise<PantryLocation[]> {
try {
const res = await this.db.query<PantryLocation>('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getPantryLocations:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getPantryLocations');
throw new Error('Failed to get pantry locations.');
}
}
@@ -240,7 +240,7 @@ export class ShoppingRepository {
* @param name The name of the new location (e.g., "Fridge").
* @returns A promise that resolves to the newly created PantryLocation object.
*/
async createPantryLocation(userId: string, name: string): Promise<PantryLocation> {
async createPantryLocation(userId: string, name: string, logger: Logger): Promise<PantryLocation> {
try {
const res = await this.db.query<PantryLocation>(
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
@@ -253,7 +253,7 @@ export class ShoppingRepository {
} else if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('User not found');
}
logger.error('Database error in createPantryLocation:', { error });
logger.error({ err: error, userId, name }, 'Database error in createPantryLocation');
throw new Error('Failed to create pantry location.');
}
}
@@ -264,7 +264,7 @@ export class ShoppingRepository {
* @param updates A partial object of the fields to update (e.g., quantity, is_purchased).
* @returns A promise that resolves to the updated ShoppingListItem object.
*/
async updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> {
async updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>, logger: Logger): Promise<ShoppingListItem> {
try {
if (Object.keys(updates).length === 0) {
throw new Error('No valid fields to update.');
@@ -304,7 +304,7 @@ export class ShoppingRepository {
if (error instanceof NotFoundError || (error instanceof Error && error.message.startsWith('No valid fields'))) {
throw error;
}
logger.error('Database error in updateShoppingListItem:', { error, itemId, updates });
logger.error({ err: error, itemId, updates }, 'Database error in updateShoppingListItem');
throw new Error('Failed to update shopping list item.');
}
}
@@ -316,7 +316,7 @@ export class ShoppingRepository {
* @param totalSpentCents Optional total amount spent on the trip.
* @returns A promise that resolves to the ID of the newly created shopping trip.
*/
async completeShoppingList(shoppingListId: number, userId: string, totalSpentCents?: number): Promise<number> {
async completeShoppingList(shoppingListId: number, userId: string, logger: Logger, totalSpentCents?: number): Promise<number> {
try {
const res = await this.db.query<{ complete_shopping_list: number }>(
'SELECT public.complete_shopping_list($1, $2, $3)',
@@ -328,7 +328,7 @@ export class ShoppingRepository {
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('The specified shopping list does not exist.');
}
logger.error('Database error in completeShoppingList:', { error });
logger.error({ err: error, shoppingListId, userId }, 'Database error in completeShoppingList');
throw new Error('Failed to complete shopping list.');
}
}
@@ -338,7 +338,7 @@ export class ShoppingRepository {
* @param userId The ID of the user.
* @returns A promise that resolves to an array of ShoppingTrip objects.
*/
async getShoppingTripHistory(userId: string): Promise<ShoppingTrip[]> {
async getShoppingTripHistory(userId: string, logger: Logger): Promise<ShoppingTrip[]> {
try {
const query = `
SELECT
@@ -366,7 +366,7 @@ export class ShoppingRepository {
const res = await this.db.query<ShoppingTrip>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getShoppingTripHistory:', { error, userId });
logger.error({ err: error, userId }, 'Database error in getShoppingTripHistory');
throw new Error('Failed to retrieve shopping trip history.');
}
}
@@ -377,7 +377,7 @@ export class ShoppingRepository {
* @param receiptImageUrl The URL where the receipt image is stored.
* @returns A promise that resolves to the newly created Receipt object.
*/
async createReceipt(userId: string, receiptImageUrl: string): Promise<Receipt> {
async createReceipt(userId: string, receiptImageUrl: string, logger: Logger): Promise<Receipt> {
try {
const res = await this.db.query<Receipt>(
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
@@ -391,7 +391,7 @@ export class ShoppingRepository {
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('User not found');
}
logger.error('Database error in createReceipt:', { error, userId });
logger.error({ err: error, userId, receiptImageUrl }, 'Database error in createReceipt');
throw new Error('Failed to create receipt record.');
}
}
@@ -404,7 +404,8 @@ export class ShoppingRepository {
*/
async processReceiptItems(
receiptId: number,
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[],
logger: Logger
): Promise<void> {
try {
await withTransaction(async (client) => {
@@ -414,13 +415,13 @@ export class ShoppingRepository {
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
});
} catch (error) {
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
logger.error({ err: error, receiptId }, 'Database transaction error in processReceiptItems');
// After the transaction fails and is rolled back by withTransaction,
// update the receipt status in a separate, non-transactional query.
try {
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [receiptId]);
} catch (updateError) {
logger.error('Failed to update receipt status to "failed" after transaction rollback.', { updateError, receiptId });
logger.error({ updateError, receiptId }, 'Failed to update receipt status to "failed" after transaction rollback.');
}
throw new Error('Failed to process and save receipt items.');
}
@@ -431,12 +432,12 @@ export class ShoppingRepository {
* @param receiptId The ID of the receipt to check.
* @returns A promise that resolves to an array of potential deals.
*/
async findDealsForReceipt(receiptId: number): Promise<ReceiptDeal[]> {
async findDealsForReceipt(receiptId: number, logger: Logger): Promise<ReceiptDeal[]> {
try {
const res = await this.db.query<ReceiptDeal>('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]);
return res.rows;
} catch (error) {
logger.error('Database error in findDealsForReceipt:', { error, receiptId });
logger.error({ err: error, receiptId }, 'Database error in findDealsForReceipt');
throw new Error('Failed to find deals for receipt.');
}
}
@@ -446,7 +447,7 @@ export class ShoppingRepository {
* @param receiptId The ID of the receipt.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
async findReceiptOwner(receiptId: number): Promise<{ user_id: string } | undefined> {
async findReceiptOwner(receiptId: number, logger: Logger): Promise<{ user_id: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string }>(
'SELECT user_id FROM public.receipts WHERE receipt_id = $1',
@@ -454,7 +455,7 @@ export class ShoppingRepository {
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findReceiptOwner:', { error, receiptId });
logger.error({ err: error, receiptId }, 'Database error in findReceiptOwner');
throw new Error('Failed to retrieve receipt owner from database.');
}
}

View File

@@ -19,6 +19,7 @@ vi.mock('../logger.server', () => ({
error: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./user.db');
@@ -70,7 +71,7 @@ describe('User DB Service', () => {
const mockUser = { user_id: '123', email: 'test@example.com' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser] });
const result = await userRepo.findUserByEmail('test@example.com');
const result = await userRepo.findUserByEmail('test@example.com', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['test@example.com']);
expect(result).toEqual(mockUser);
@@ -78,7 +79,7 @@ describe('User DB Service', () => {
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserByEmail('notfound@example.com');
const result = await userRepo.findUserByEmail('notfound@example.com', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['notfound@example.com']);
expect(result).toBeUndefined();
});
@@ -86,7 +87,8 @@ describe('User DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.findUserByEmail('test@example.com')).rejects.toThrow('Failed to retrieve user from database.');
await expect(userRepo.findUserByEmail('test@example.com', mockLogger)).rejects.toThrow('Failed to retrieve user from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'test@example.com' }, 'Database error in findUserByEmail');
});
});
@@ -104,7 +106,7 @@ describe('User DB Service', () => {
return callback(mockClient as any);
});
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' });
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' }, mockLogger);
expect(result).toEqual(mockProfile);
expect(withTransaction).toHaveBeenCalledTimes(1);
@@ -119,7 +121,8 @@ describe('User DB Service', () => {
throw dbError;
});
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('Failed to create user in database.');
await expect(userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger)).rejects.toThrow('Failed to create user in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'fail@example.com' }, 'Error during createUser transaction');
});
it('should rollback the transaction if fetching the final profile fails', async () => {
@@ -135,7 +138,8 @@ describe('User DB Service', () => {
throw dbError;
});
await expect(userRepo.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Failed to create user in database.');
await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow('Failed to create user in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'fail@example.com' }, 'Error during createUser transaction');
});
it('should throw UniqueConstraintError if the email already exists', async () => {
@@ -145,7 +149,7 @@ describe('User DB Service', () => {
vi.mocked(withTransaction).mockRejectedValue(dbError);
try {
await userRepo.createUser('exists@example.com', 'pass', {});
await userRepo.createUser('exists@example.com', 'pass', {}, mockLogger);
expect.fail('Expected createUser to throw UniqueConstraintError');
} catch (error: any) {
expect(error).toBeInstanceOf(UniqueConstraintError);
@@ -153,6 +157,7 @@ describe('User DB Service', () => {
}
expect(withTransaction).toHaveBeenCalledTimes(1);
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'exists@example.com' }, 'Error during createUser transaction');
});
});
@@ -161,7 +166,7 @@ describe('User DB Service', () => {
const mockUserWithProfile = { user_id: '123', email: 'test@example.com', full_name: 'Test User', role: 'user' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUserWithProfile] });
const result = await userRepo.findUserWithProfileByEmail('test@example.com');
const result = await userRepo.findUserWithProfileByEmail('test@example.com', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('JOIN public.profiles'), ['test@example.com']);
expect(result).toEqual(mockUserWithProfile);
@@ -169,72 +174,76 @@ describe('User DB Service', () => {
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserWithProfileByEmail('notfound@example.com');
const result = await userRepo.findUserWithProfileByEmail('notfound@example.com', mockLogger);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.findUserWithProfileByEmail('test@example.com')).rejects.toThrow('Failed to retrieve user with profile from database.');
await expect(userRepo.findUserWithProfileByEmail('test@example.com', mockLogger)).rejects.toThrow('Failed to retrieve user with profile from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'test@example.com' }, 'Database error in findUserWithProfileByEmail');
});
});
describe('findUserById', () => {
it('should query for a user by their ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
await userRepo.findUserById('123');
await userRepo.findUserById('123', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']);
});
it('should throw NotFoundError if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(userRepo.findUserById('not-found-id')).rejects.toThrow(NotFoundError);
await expect(userRepo.findUserById('not-found-id', mockLogger)).rejects.toThrow(NotFoundError);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.findUserById('123')).rejects.toThrow('Failed to retrieve user by ID from database.');
await expect(userRepo.findUserById('123', mockLogger)).rejects.toThrow('Failed to retrieve user by ID from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserById');
});
});
describe('findUserWithPasswordHashById', () => {
it('should query for a user and their password hash by ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123', password_hash: 'hash' }] });
await userRepo.findUserWithPasswordHashById('123');
await userRepo.findUserWithPasswordHashById('123', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT user_id, email, password_hash'), ['123']);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.findUserWithPasswordHashById('123')).rejects.toThrow('Failed to retrieve user with sensitive data by ID from database.');
await expect(userRepo.findUserWithPasswordHashById('123', mockLogger)).rejects.toThrow('Failed to retrieve user with sensitive data by ID from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserWithPasswordHashById');
});
});
describe('findUserProfileById', () => {
it('should query for a user profile by user ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
await userRepo.findUserProfileById('123');
await userRepo.findUserProfileById('123', mockLogger);
// The actual query uses 'p.user_id' due to the join alias
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']);
});
it('should throw NotFoundError if user profile is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(userRepo.findUserById('not-found-id')).rejects.toThrow('User with ID not-found-id not found.');
await expect(userRepo.findUserProfileById('not-found-id', mockLogger)).rejects.toThrow('Profile not found for this user.');
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserWithPasswordHashById('not-found-id');
const result = await userRepo.findUserWithPasswordHashById('not-found-id', mockLogger);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.findUserProfileById('123')).rejects.toThrow('Failed to retrieve user profile from database.');
await expect(userRepo.findUserProfileById('123', mockLogger)).rejects.toThrow('Failed to retrieve user profile from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserProfileById');
});
});
@@ -243,7 +252,7 @@ describe('User DB Service', () => {
const mockProfile: Profile = { user_id: '123', full_name: 'Updated Name', role: 'user', points: 0 };
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
await userRepo.updateUserProfile('123', { full_name: 'Updated Name' });
await userRepo.updateUserProfile('123', { full_name: 'Updated Name' }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.profiles'), expect.any(Array));
});
@@ -252,7 +261,7 @@ describe('User DB Service', () => {
const mockProfile: Profile = { user_id: '123', avatar_url: 'new-avatar.png', role: 'user', points: 0 };
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' });
await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('avatar_url = $1'), ['new-avatar.png', '123']);
});
@@ -261,7 +270,7 @@ describe('User DB Service', () => {
const mockProfile: Profile = { user_id: '123', address_id: 99, role: 'user', points: 0 };
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
await userRepo.updateUserProfile('123', { address_id: 99 });
await userRepo.updateUserProfile('123', { address_id: 99 }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('address_id = $1'), [99, '123']);
});
@@ -272,7 +281,7 @@ describe('User DB Service', () => {
// we mock the underlying `db.query` call that `findUserProfileById` makes.
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
const result = await userRepo.updateUserProfile('123', { full_name: undefined });
const result = await userRepo.updateUserProfile('123', { full_name: undefined }, mockLogger);
// Check that it calls query for finding profile (since no updates were made)
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT'), expect.any(Array));
@@ -282,91 +291,96 @@ describe('User DB Service', () => {
it('should throw an error if the user to update is not found', async () => {
// Simulate the DB returning 0 rows affected
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(userRepo.updateUserProfile('999', { full_name: 'Fail' })).rejects.toThrow('User not found or user does not have permission to update.');
await expect(userRepo.updateUserProfile('999', { full_name: 'Fail' }, mockLogger)).rejects.toThrow('User not found or user does not have permission to update.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(userRepo.updateUserProfile('123', { full_name: 'Fail' })).rejects.toThrow('Failed to update user profile in database.');
await expect(userRepo.updateUserProfile('123', { full_name: 'Fail' }, mockLogger)).rejects.toThrow('Failed to update user profile in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123', profileData: { full_name: 'Fail' } }, 'Database error in updateUserProfile');
});
});
describe('updateUserPreferences', () => {
it('should execute an UPDATE query for user preferences', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{}] });
await userRepo.updateUserPreferences('123', { darkMode: true });
await userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("SET preferences = COALESCE(preferences, '{}'::jsonb) || $1"), [{ darkMode: true }, '123']);
});
it('should throw an error if the user to update is not found', async () => {
// Simulate the DB returning 0 rows affected
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(userRepo.updateUserPreferences('999', { darkMode: true })).rejects.toThrow('User not found or user does not have permission to update.');
await expect(userRepo.updateUserPreferences('999', { darkMode: true }, mockLogger)).rejects.toThrow('User not found or user does not have permission to update.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(userRepo.updateUserPreferences('123', { darkMode: true })).rejects.toThrow('Failed to update user preferences in database.');
await expect(userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger)).rejects.toThrow('Failed to update user preferences in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123', preferences: { darkMode: true } }, 'Database error in updateUserPreferences');
});
});
describe('updateUserPassword', () => {
it('should execute an UPDATE query for the user password', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.updateUserPassword('123', 'newhash');
await userRepo.updateUserPassword('123', 'newhash', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET password_hash = $1 WHERE user_id = $2', ['newhash', '123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(userRepo.updateUserPassword('123', 'newhash')).rejects.toThrow('Failed to update user password in database.');
await expect(userRepo.updateUserPassword('123', 'newhash', mockLogger)).rejects.toThrow('Failed to update user password in database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in updateUserPassword');
});
});
describe('deleteUserById', () => {
it('should execute a DELETE query for the user', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.deleteUserById('123');
await userRepo.deleteUserById('123', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.users WHERE user_id = $1', ['123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(userRepo.deleteUserById('123')).rejects.toThrow('Failed to delete user from database.');
await expect(userRepo.deleteUserById('123', mockLogger)).rejects.toThrow('Failed to delete user from database.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in deleteUserById');
});
});
describe('saveRefreshToken', () => {
it('should execute an UPDATE query to save the refresh token', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.saveRefreshToken('123', 'new-token');
await userRepo.saveRefreshToken('123', 'new-token', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', ['new-token', '123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(userRepo.saveRefreshToken('123', 'new-token')).rejects.toThrow('Failed to save refresh token.');
await expect(userRepo.saveRefreshToken('123', 'new-token', mockLogger)).rejects.toThrow('Failed to save refresh token.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in saveRefreshToken');
});
});
describe('findUserByRefreshToken', () => {
it('should query for a user by their refresh token', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
await userRepo.findUserByRefreshToken('a-token');
await userRepo.findUserByRefreshToken('a-token', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE refresh_token = $1'), ['a-token']);
});
it('should throw NotFoundError if token is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(userRepo.findUserByRefreshToken('a-token')).rejects.toThrow(NotFoundError);
await expect(userRepo.findUserByRefreshToken('a-token')).rejects.toThrow('User not found for the given refresh token.');
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(NotFoundError);
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow('User not found for the given refresh token.');
});
});
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');
await userRepo.deleteRefreshToken('a-token', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', ['a-token']
);
@@ -375,9 +389,10 @@ describe('User DB Service', () => {
it('should not throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
// The function is designed to swallow errors, so we expect it to resolve.
await expect(userRepo.deleteRefreshToken('a-token')).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(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in deleteRefreshToken');
});
});
@@ -385,7 +400,7 @@ describe('User DB Service', () => {
it('should execute DELETE and INSERT queries', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const expires = new Date();
await userRepo.createPasswordResetToken('123', 'token-hash', expires);
await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE user_id = $1', ['123']);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.password_reset_tokens'), ['123', 'token-hash', expires]);
});
@@ -394,35 +409,44 @@ describe('User DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date())).rejects.toThrow(ForeignKeyConstraintError);
await expect(userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
const expires = new Date();
await expect(userRepo.createPasswordResetToken('123', 'token-hash', expires)).rejects.toThrow('Failed to create password reset token.');
await expect(userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger)).rejects.toThrow('Failed to create password reset token.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in createPasswordResetToken');
});
});
describe('getValidResetTokens', () => {
it('should query for tokens where expires_at > NOW()', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.getValidResetTokens();
await userRepo.getValidResetTokens(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE expires_at > NOW()'));
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(userRepo.getValidResetTokens()).rejects.toThrow('Failed to retrieve valid reset tokens.');
await expect(userRepo.getValidResetTokens(mockLogger)).rejects.toThrow('Failed to retrieve valid reset tokens.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in getValidResetTokens');
});
});
describe('deleteResetToken', () => {
it('should execute a DELETE query for the token hash', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.deleteResetToken('token-hash');
await userRepo.deleteResetToken('token-hash', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', ['token-hash']);
});
it('should log an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await userRepo.deleteResetToken('token-hash', mockLogger);
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), tokenHash: 'token-hash' }, 'Database error in deleteResetToken');
});
});
describe('exportUserData', () => {
@@ -438,21 +462,21 @@ describe('User DB Service', () => {
const { PersonalizationRepository } = await import('./personalization.db');
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById')
.mockResolvedValue({ user_id: '123' } as Profile);
.mockResolvedValue({ user_id: '123' } as any);
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems')
.mockResolvedValue([]);
const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists')
.mockResolvedValue([]);
await exportUserData('123');
await exportUserData('123', mockLogger);
// Verify that withTransaction was called
expect(withTransaction).toHaveBeenCalledTimes(1);
// Verify the repository methods were called inside the transaction
expect(findProfileSpy).toHaveBeenCalledWith('123');
expect(getWatchedItemsSpy).toHaveBeenCalledWith('123');
expect(getShoppingListsSpy).toHaveBeenCalledWith('123');
expect(findProfileSpy).toHaveBeenCalledWith('123', expect.any(Object));
expect(getWatchedItemsSpy).toHaveBeenCalledWith('123', expect.any(Object));
expect(getShoppingListsSpy).toHaveBeenCalledWith('123', expect.any(Object));
});
it('should throw an error if the user profile is not found', async () => {
@@ -462,7 +486,7 @@ describe('User DB Service', () => {
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new NotFoundError('Profile not found'));
// Act & Assert: The outer function catches the NotFoundError and re-throws a generic one.
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
await expect(exportUserData('123', mockLogger)).rejects.toThrow('Failed to export user data.');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
@@ -471,7 +495,7 @@ describe('User DB Service', () => {
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new Error('DB Error'));
// Act & Assert
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
await expect(exportUserData('123', mockLogger)).rejects.toThrow('Failed to export user data.');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});
@@ -479,7 +503,7 @@ describe('User DB Service', () => {
describe('followUser', () => {
it('should execute an INSERT query to create a follow relationship', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.followUser('follower-1', 'following-1');
await userRepo.followUser('follower-1', 'following-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT (follower_id, following_id) DO NOTHING',
['follower-1', 'following-1']
@@ -490,20 +514,21 @@ describe('User DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.followUser('follower-1', 'non-existent-user')).rejects.toThrow(ForeignKeyConstraintError);
await expect(userRepo.followUser('follower-1', 'non-existent-user', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.followUser('follower-1', 'following-1')).rejects.toThrow('Failed to follow user.');
await expect(userRepo.followUser('follower-1', 'following-1', mockLogger)).rejects.toThrow('Failed to follow user.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, followerId: 'follower-1', followingId: 'following-1' }, 'Database error in followUser');
});
});
describe('unfollowUser', () => {
it('should execute a DELETE query to remove a follow relationship', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await userRepo.unfollowUser('follower-1', 'following-1');
await userRepo.unfollowUser('follower-1', 'following-1', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2',
['follower-1', 'following-1']
@@ -513,7 +538,8 @@ describe('User DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.unfollowUser('follower-1', 'following-1')).rejects.toThrow('Failed to unfollow user.');
await expect(userRepo.unfollowUser('follower-1', 'following-1', mockLogger)).rejects.toThrow('Failed to unfollow user.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, followerId: 'follower-1', followingId: 'following-1' }, 'Database error in unfollowUser');
});
});
@@ -531,7 +557,7 @@ describe('User DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockFeedItems });
const result = await userRepo.getUserFeed('user-123', 10, 0);
const result = await userRepo.getUserFeed('user-123', 10, 0, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.activity_log al'),
@@ -542,14 +568,15 @@ describe('User DB Service', () => {
it('should return an empty array if the user feed is empty', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.getUserFeed('user-123', 10, 0);
const result = await userRepo.getUserFeed('user-123', 10, 0, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.getUserFeed('user-123', 10, 0)).rejects.toThrow('Failed to retrieve user feed.');
await expect(userRepo.getUserFeed('user-123', 10, 0, mockLogger)).rejects.toThrow('Failed to retrieve user feed.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', limit: 10, offset: 0 }, 'Database error in getUserFeed');
});
});
@@ -568,7 +595,7 @@ describe('User DB Service', () => {
};
mockPoolInstance.query.mockResolvedValue({ rows: [mockLoggedQuery] });
const result = await userRepo.logSearchQuery(queryData);
const result = await userRepo.logSearchQuery(queryData, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4) RETURNING *',
@@ -596,7 +623,7 @@ describe('User DB Service', () => {
};
mockPoolInstance.query.mockResolvedValue({ rows: [mockLoggedQuery] });
await userRepo.logSearchQuery(queryData);
await userRepo.logSearchQuery(queryData, mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), [null, 'anonymous search', 10, true]);
});
@@ -604,7 +631,8 @@ describe('User DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(userRepo.logSearchQuery({ query_text: 'fail' })).rejects.toThrow('Failed to log search query.');
await expect(userRepo.logSearchQuery({ query_text: 'fail' }, mockLogger)).rejects.toThrow('Failed to log search query.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, queryData: { query_text: 'fail' } }, 'Database error in logSearchQuery');
});
});
});

View File

@@ -1,7 +1,7 @@
// src/services/db/user.db.ts
import { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import type { Logger } from 'pino';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile, SearchQuery } from '../../types';
import { ShoppingRepository } from './shopping.db';
@@ -32,8 +32,8 @@ export class UserRepository {
* @param email The email of the user to find.
* @returns A promise that resolves to the user object or undefined if not found.
*/
async findUserByEmail(email: string): Promise<DbUser | undefined> {
logger.debug(`[DB findUserByEmail] Searching for user with email: ${email}`);
async findUserByEmail(email: string, logger: Logger): Promise<DbUser | undefined> {
logger.debug({ email }, `[DB findUserByEmail] Searching for user.`);
try {
const res = await this.db.query<DbUser>(
'SELECT user_id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1',
@@ -43,7 +43,7 @@ export class UserRepository {
logger.debug(`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserByEmail:', { error });
logger.error({ err: error, email }, 'Database error in findUserByEmail');
throw new Error('Failed to retrieve user from database.');
}
}
@@ -59,7 +59,8 @@ export class UserRepository {
async createUser(
email: string,
passwordHash: string | null,
profileData: { full_name?: string; avatar_url?: string }
profileData: { full_name?: string; avatar_url?: string },
logger: Logger
): Promise<UserProfile> {
return withTransaction(async (client: PoolClient) => {
logger.debug(`[DB createUser] Starting transaction for email: ${email}`);
@@ -104,7 +105,7 @@ export class UserRepository {
preferences: flatProfile.preferences,
};
logger.debug(`[DB createUser] Fetched full profile for new user:`, { user: fullUserProfile });
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
return fullUserProfile;
}).catch(error => {
// Check for specific PostgreSQL error codes
@@ -113,7 +114,7 @@ export class UserRepository {
throw new UniqueConstraintError('A user with this email address already exists.');
}
// The withTransaction helper logs the rollback, so we just log the context here.
logger.error('Error during createUser transaction:', { error });
logger.error({ err: error, email }, 'Error during createUser transaction');
throw new Error('Failed to create user in database.');
});
}
@@ -124,8 +125,8 @@ export class UserRepository {
* @param email The email of the user to find.
* @returns A promise that resolves to the combined user and profile object or undefined if not found.
*/
async findUserWithProfileByEmail(email: string): Promise<(DbUser & Profile) | undefined> {
logger.debug(`[DB findUserWithProfileByEmail] Searching for user with email: ${email}`);
async findUserWithProfileByEmail(email: string, logger: Logger): Promise<(DbUser & Profile) | undefined> {
logger.debug({ email }, `[DB findUserWithProfileByEmail] Searching for user.`);
try {
const query = `
SELECT u.*, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.address_id
@@ -136,7 +137,7 @@ export class UserRepository {
const res = await this.db.query<(DbUser & Profile)>(query, [email]);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserWithProfileByEmail:', { error });
logger.error({ err: error, email }, 'Database error in findUserWithProfileByEmail');
throw new Error('Failed to retrieve user with profile from database.');
}
}
@@ -147,7 +148,7 @@ export class UserRepository {
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
async findUserById(userId: string): Promise<{ user_id: string; email: string; }> {
async findUserById(userId: string, logger: Logger): Promise<{ user_id: string; email: string; }> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE user_id = $1',
@@ -159,7 +160,7 @@ export class UserRepository {
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in findUserById:', { error, userId });
logger.error({ err: error, userId }, 'Database error in findUserById');
throw new Error('Failed to retrieve user by ID from database.');
}
}
@@ -171,7 +172,7 @@ export class UserRepository {
* @returns A promise that resolves to the user object (id, email, password_hash) or undefined if not found.
*/
// prettier-ignore
async findUserWithPasswordHashById(userId: string): Promise<{ user_id: string; email: string; password_hash: string | null } | undefined> {
async findUserWithPasswordHashById(userId: string, logger: Logger): Promise<{ user_id: string; email: string; password_hash: string | null } | undefined> {
try {
const res = await this.db.query<{ user_id: string; email: string; password_hash: string | null }>(
'SELECT user_id, email, password_hash FROM public.users WHERE user_id = $1',
@@ -179,7 +180,7 @@ export class UserRepository {
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserWithPasswordHashById:', { error });
logger.error({ err: error, userId }, 'Database error in findUserWithPasswordHashById');
throw new Error('Failed to retrieve user with sensitive data by ID from database.');
}
}
@@ -190,7 +191,7 @@ export class UserRepository {
* @returns A promise that resolves to the user's profile object or undefined if not found.
*/
// prettier-ignore
async findUserProfileById(userId: string): Promise<Profile> {
async findUserProfileById(userId: string, logger: Logger): Promise<Profile> {
try {
const res = await this.db.query<Profile>(
`SELECT
@@ -220,7 +221,7 @@ export class UserRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in findUserProfileById:', { error });
logger.error({ err: error, userId }, 'Database error in findUserProfileById');
throw new Error('Failed to retrieve user profile from database.');
}
}
@@ -232,7 +233,7 @@ export class UserRepository {
* @returns A promise that resolves to the updated profile object.
*/
// prettier-ignore
async updateUserProfile(userId: string, profileData: Partial<Pick<Profile, 'full_name' | 'avatar_url' | 'address_id'>>): Promise<Profile> {
async updateUserProfile(userId: string, profileData: Partial<Pick<Profile, 'full_name' | 'avatar_url' | 'address_id'>>, logger: Logger): Promise<Profile> {
try {
const { full_name, avatar_url, address_id } = profileData;
const fieldsToUpdate = [];
@@ -244,7 +245,7 @@ export class UserRepository {
if (address_id !== undefined) { fieldsToUpdate.push(`address_id = $${paramIndex++}`); values.push(address_id); }
if (fieldsToUpdate.length === 0) {
return this.findUserProfileById(userId) as Promise<Profile>;
return this.findUserProfileById(userId, logger) as Promise<Profile>;
}
values.push(userId);
@@ -266,7 +267,7 @@ export class UserRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in updateUserProfile:', { error });
logger.error({ err: error, userId, profileData }, 'Database error in updateUserProfile');
throw new Error('Failed to update user profile in database.');
}
}
@@ -278,7 +279,7 @@ export class UserRepository {
* @returns A promise that resolves to the updated profile object.
*/
// prettier-ignore
async updateUserPreferences(userId: string, preferences: Profile['preferences']): Promise<Profile> {
async updateUserPreferences(userId: string, preferences: Profile['preferences'], logger: Logger): Promise<Profile> {
try {
const res = await this.db.query<Profile>(
`UPDATE public.profiles
@@ -295,7 +296,7 @@ export class UserRepository {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in updateUserPreferences:', { error });
logger.error({ err: error, userId, preferences }, 'Database error in updateUserPreferences');
throw new Error('Failed to update user preferences in database.');
}
}
@@ -306,14 +307,14 @@ export class UserRepository {
* @param passwordHash The new bcrypt hashed password.
*/
// prettier-ignore
async updateUserPassword(userId: string, passwordHash: string): Promise<void> {
async updateUserPassword(userId: string, passwordHash: string, logger: Logger): Promise<void> {
try {
await this.db.query(
'UPDATE public.users SET password_hash = $1 WHERE user_id = $2',
[passwordHash, userId]
);
} catch (error) {
logger.error('Database error in updateUserPassword:', { error });
logger.error({ err: error, userId }, 'Database error in updateUserPassword');
throw new Error('Failed to update user password in database.');
}
}
@@ -323,11 +324,11 @@ export class UserRepository {
* @param id The UUID of the user to delete.
*/
// prettier-ignore
async deleteUserById(userId: string): Promise<void> {
async deleteUserById(userId: string, logger: Logger): Promise<void> {
try {
await this.db.query('DELETE FROM public.users WHERE user_id = $1', [userId]);
} catch (error) {
logger.error('Database error in deleteUserById:', { error });
logger.error({ err: error, userId }, 'Database error in deleteUserById');
throw new Error('Failed to delete user from database.');
}
}
@@ -338,14 +339,14 @@ export class UserRepository {
* @param refreshToken The new refresh token to save.
*/
// prettier-ignore
async saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
async saveRefreshToken(userId: string, refreshToken: string, logger: Logger): Promise<void> {
try {
await this.db.query(
'UPDATE public.users SET refresh_token = $1 WHERE user_id = $2',
[refreshToken, userId]
);
} catch (error) {
logger.error('Database error in saveRefreshToken:', { error });
logger.error({ err: error, userId }, 'Database error in saveRefreshToken');
throw new Error('Failed to save refresh token.');
}
}
@@ -356,7 +357,7 @@ export class UserRepository {
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
async findUserByRefreshToken(refreshToken: string): Promise<{ user_id: string; email: string; }> {
async findUserByRefreshToken(refreshToken: string, logger: Logger): Promise<{ user_id: string; email: string; }> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
@@ -368,7 +369,7 @@ export class UserRepository {
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in findUserByRefreshToken:', { error });
logger.error({ err: error }, 'Database error in findUserByRefreshToken');
throw new Error('Failed to find user by refresh token.'); // Generic error for other failures
}
}
@@ -377,11 +378,11 @@ export class UserRepository {
* Deletes a refresh token from the database by setting it to NULL.
* @param refreshToken The refresh token to delete.
*/
async deleteRefreshToken(refreshToken: string): Promise<void> {
async deleteRefreshToken(refreshToken: string, logger: Logger): Promise<void> {
try {
await this.db.query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [refreshToken]);
} catch (error) {
logger.error('Database error in deleteRefreshToken:', { error });
logger.error({ err: error }, 'Database error in deleteRefreshToken');
}
}
@@ -392,7 +393,7 @@ export class UserRepository {
* @param expiresAt The timestamp when the token expires.
*/
// prettier-ignore
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date, logger: Logger): Promise<void> {
const client = this.db as PoolClient;
try {
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
@@ -404,7 +405,7 @@ export class UserRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createPasswordResetToken:', { error });
logger.error({ err: error, userId }, 'Database error in createPasswordResetToken');
throw new Error('Failed to create password reset token.');
}
}
@@ -414,14 +415,14 @@ export class UserRepository {
* @returns A promise that resolves to an array of valid token records.
*/
// prettier-ignore
async getValidResetTokens(): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> {
async getValidResetTokens(logger: Logger): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> {
try {
const res = await this.db.query<{ user_id: string; token_hash: string; expires_at: Date }>(
'SELECT user_id, token_hash, expires_at FROM public.password_reset_tokens WHERE expires_at > NOW()'
);
return res.rows;
} catch (error) {
logger.error('Database error in getValidResetTokens:', { error });
logger.error({ err: error }, 'Database error in getValidResetTokens');
throw new Error('Failed to retrieve valid reset tokens.');
}
}
@@ -431,11 +432,11 @@ export class UserRepository {
* @param tokenHash The hashed token to delete.
*/
// prettier-ignore
async deleteResetToken(tokenHash: string): Promise<void> {
async deleteResetToken(tokenHash: string, logger: Logger): Promise<void> {
try {
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
} catch (error) {
logger.error('Database error in deleteResetToken:', { error });
logger.error({ err: error, tokenHash }, 'Database error in deleteResetToken');
}
}
@@ -444,7 +445,7 @@ export class UserRepository {
* @param followerId The ID of the user who is following.
* @param followingId The ID of the user being followed.
*/
async followUser(followerId: string, followingId: string): Promise<void> {
async followUser(followerId: string, followingId: string, logger: Logger): Promise<void> {
try {
await this.db.query(
'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT (follower_id, following_id) DO NOTHING',
@@ -457,7 +458,7 @@ export class UserRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or both users do not exist.');
}
logger.error('Database error in followUser:', { error, followerId, followingId });
logger.error({ err: error, followerId, followingId }, 'Database error in followUser');
throw new Error('Failed to follow user.');
}
}
@@ -467,11 +468,11 @@ export class UserRepository {
* @param followerId The ID of the user who is unfollowing.
* @param followingId The ID of the user being unfollowed.
*/
async unfollowUser(followerId: string, followingId: string): Promise<void> {
async unfollowUser(followerId: string, followingId: string, logger: Logger): Promise<void> {
try {
await this.db.query('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', [followerId, followingId]);
} catch (error) {
logger.error('Database error in unfollowUser:', { error, followerId, followingId });
logger.error({ err: error, followerId, followingId }, 'Database error in unfollowUser');
throw new Error('Failed to unfollow user.');
}
}
@@ -483,7 +484,7 @@ export class UserRepository {
* @param offset The number of feed items to skip for pagination.
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
async getUserFeed(userId: string, limit: number, offset: number): Promise<ActivityLogItem[]> {
async getUserFeed(userId: string, limit: number, offset: number, logger: Logger): Promise<ActivityLogItem[]> {
try {
const query = `
SELECT al.*, p.full_name as user_full_name, p.avatar_url as user_avatar_url
@@ -497,7 +498,7 @@ export class UserRepository {
const res = await this.db.query<ActivityLogItem>(query, [userId, limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserFeed:', { error, userId });
logger.error({ err: error, userId, limit, offset }, 'Database error in getUserFeed');
throw new Error('Failed to retrieve user feed.');
}
}
@@ -507,7 +508,7 @@ export class UserRepository {
* @param queryData The search query data to log.
* @returns A promise that resolves to the created SearchQuery object.
*/
async logSearchQuery(queryData: Omit<SearchQuery, 'search_query_id' | 'created_at'>): Promise<SearchQuery> {
async logSearchQuery(queryData: Omit<SearchQuery, 'search_query_id' | 'created_at'>, logger: Logger): Promise<SearchQuery> {
const { user_id, query_text, result_count, was_successful } = queryData;
try {
const res = await this.db.query<SearchQuery>(
@@ -516,7 +517,7 @@ export class UserRepository {
);
return res.rows[0];
} catch (error) {
logger.error('Database error in logSearchQuery:', { error, queryData });
logger.error({ err: error, queryData }, 'Database error in logSearchQuery');
throw new Error('Failed to log search query.');
}
}
@@ -528,7 +529,7 @@ export class UserRepository {
* @returns A promise that resolves to an object containing all user data.
*/
// prettier-ignore
export async function exportUserData(userId: string): Promise<{ profile: Profile; watchedItems: MasterGroceryItem[]; shoppingLists: ShoppingList[] }> {
export async function exportUserData(userId: string, logger: Logger): Promise<{ profile: Profile; watchedItems: MasterGroceryItem[]; shoppingLists: ShoppingList[] }> {
try {
return await withTransaction(async (client) => {
const userRepo = new UserRepository(client);
@@ -536,9 +537,9 @@ export async function exportUserData(userId: string): Promise<{ profile: Profile
const personalizationRepo = new PersonalizationRepository(client);
// Run queries in parallel for efficiency within the transaction
const profileQuery = userRepo.findUserProfileById(userId);
const watchedItemsQuery = personalizationRepo.getWatchedItems(userId);
const shoppingListsQuery = shoppingRepo.getShoppingLists(userId);
const profileQuery = userRepo.findUserProfileById(userId, logger);
const watchedItemsQuery = personalizationRepo.getWatchedItems(userId, logger);
const shoppingListsQuery = shoppingRepo.getShoppingLists(userId, logger);
const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]);
@@ -550,7 +551,7 @@ export async function exportUserData(userId: string): Promise<{ profile: Profile
return { profile, watchedItems, shoppingLists };
});
} catch (error) {
logger.error('Database error in exportUserData:', { error, userId });
logger.error({ err: error, userId }, 'Database error in exportUserData');
throw new Error('Failed to export user data.');
}
}

View File

@@ -35,6 +35,7 @@ vi.mock('./logger.server', () => ({
// Now that mocks are set up, we can import the service under test.
import { sendPasswordResetEmail, sendWelcomeEmail, sendDealNotificationEmail } from './emailService.server';
import { logger } from './logger.server';
describe('Email Service (Server)', () => {
beforeEach(async () => {
@@ -55,7 +56,7 @@ describe('Email Service (Server)', () => {
const to = 'test@example.com';
const resetLink = 'http://localhost:3000/reset/mock-token-123';
await sendPasswordResetEmail(to, resetLink);
await sendPasswordResetEmail(to, resetLink, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, text: string, html: string };
@@ -75,7 +76,7 @@ describe('Email Service (Server)', () => {
mocks.sendMail.mockResolvedValue({ messageId: 'test-id', message: Buffer.from('content') });
await sendWelcomeEmail(to, name);
await sendWelcomeEmail(to, name, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, text: string, html: string };
@@ -92,7 +93,7 @@ describe('Email Service (Server)', () => {
mocks.sendMail.mockResolvedValue({ messageId: 'test-id', message: Buffer.from('content') });
await sendWelcomeEmail(to, null);
await sendWelcomeEmail(to, null, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, text: string, html: string };
@@ -113,7 +114,7 @@ describe('Email Service (Server)', () => {
const to = 'deal.hunter@example.com';
const name = 'Deal Hunter';
await sendDealNotificationEmail(to, name, mockDeals as any);
await sendDealNotificationEmail(to, name, mockDeals as any, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, text: string, html: string };
@@ -135,7 +136,7 @@ describe('Email Service (Server)', () => {
it('should send a generic email when name is null', async () => {
const to = 'anonymous.user@example.com';
await sendDealNotificationEmail(to, null, mockDeals as any);
await sendDealNotificationEmail(to, null, mockDeals as any, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, html: string };
@@ -143,5 +144,19 @@ describe('Email Service (Server)', () => {
expect(mailOptions.to).toBe(to);
expect(mailOptions.html).toContain('Hi there,');
});
it('should log an error if sendMail fails', async () => {
const to = 'fail@example.com';
const name = 'Failure';
const emailError = new Error('SMTP Connection Failed');
mocks.sendMail.mockRejectedValue(emailError);
await expect(sendDealNotificationEmail(to, name, mockDeals as any, logger)).rejects.toThrow(emailError);
expect(logger.error).toHaveBeenCalledWith(
{ err: emailError, to, subject: 'New Deals Found on Your Watched Items!' },
'Failed to send email.'
);
});
});
});

View File

@@ -4,7 +4,7 @@
* It is configured via environment variables and should only be used on the server.
*/
import nodemailer from 'nodemailer';
import { logger } from './logger.server';
import type { Logger } from 'pino';
import { WatchedItemDeal } from '../types';
// 1. Create a Nodemailer transporter using SMTP configuration from environment variables.
@@ -31,7 +31,7 @@ interface EmailOptions {
* Sends an email using the pre-configured transporter.
* @param options The email options, including recipient, subject, and body.
*/
export const sendEmail = async (options: EmailOptions) => {
export const sendEmail = async (options: EmailOptions, logger: Logger) => {
const mailOptions = {
from: `"Flyer Crawler" <${process.env.SMTP_FROM_EMAIL}>`, // sender address
to: options.to,
@@ -42,9 +42,9 @@ export const sendEmail = async (options: EmailOptions) => {
try {
const info = await transporter.sendMail(mailOptions);
logger.info(`Email sent successfully. Message ID: ${info.messageId}`, { to: options.to, subject: options.subject });
logger.info({ to: options.to, subject: options.subject, messageId: info.messageId }, `Email sent successfully.`);
} catch (error) {
logger.error('Failed to send email.', { error, to: options.to, subject: options.subject });
logger.error({ err: error, to: options.to, subject: options.subject }, 'Failed to send email.');
// Re-throwing the error is important so the background job knows it failed.
throw error;
}
@@ -57,7 +57,7 @@ export const sendEmail = async (options: EmailOptions) => {
* @param name The recipient's name (can be null).
* @param deals An array of deals found for the user.
*/
export const sendDealNotificationEmail = async (to: string, name: string | null, deals: WatchedItemDeal[]) => {
export const sendDealNotificationEmail = async (to: string, name: string | null, deals: WatchedItemDeal[], logger: Logger) => {
const recipientName = name || 'there';
const subject = `New Deals Found on Your Watched Items!`;
@@ -90,7 +90,7 @@ export const sendDealNotificationEmail = async (to: string, name: string | null,
subject,
text,
html,
});
}, logger);
};
/**
@@ -98,7 +98,7 @@ export const sendDealNotificationEmail = async (to: string, name: string | null,
* @param to The recipient's email address.
* @param token The unique password reset token.
*/
export const sendPasswordResetEmail = async (to: string, token: string) => {
export const sendPasswordResetEmail = async (to: string, token: string, logger: Logger) => {
const subject = 'Your Password Reset Request';
// Construct the full reset URL using the frontend base URL from environment variables.
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
@@ -121,7 +121,7 @@ export const sendPasswordResetEmail = async (to: string, token: string) => {
subject,
text,
html,
});
}, logger);
};
/**
@@ -129,7 +129,7 @@ export const sendPasswordResetEmail = async (to: string, token: string) => {
* @param to The recipient's email address.
* @param name The recipient's name (can be null).
*/
export const sendWelcomeEmail = async (to: string, name: string | null) => {
export const sendWelcomeEmail = async (to: string, name: string | null, logger: Logger) => {
const recipientName = name || 'there';
const subject = 'Welcome to Flyer Crawler!';
@@ -149,5 +149,5 @@ export const sendWelcomeEmail = async (to: string, name: string | null) => {
subject,
text,
html,
});
}, logger);
};

View File

@@ -1,6 +1,7 @@
// src/services/flyerDataTransformer.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerDataTransformer } from './flyerDataTransformer';
import { logger as mockLogger } from './logger.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
import type { z } from 'zod';
import type { AiFlyerDataSchema } from './flyerProcessingService.server';
@@ -10,6 +11,10 @@ vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
}));
vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }
}));
describe('FlyerDataTransformer', () => {
let transformer: FlyerDataTransformer;
@@ -39,7 +44,7 @@ describe('FlyerDataTransformer', () => {
const userId = 'user-xyz-456';
// Act
const { flyerData, itemsForDb } = await transformer.transform(extractedData, imagePaths, originalFileName, checksum, userId);
const { flyerData, itemsForDb } = await transformer.transform(extractedData, imagePaths, originalFileName, checksum, userId, mockLogger);
// Assert
// 1. Check flyer data
@@ -73,7 +78,7 @@ describe('FlyerDataTransformer', () => {
expect((itemsForDb[0] as any).updated_at).toBeTypeOf('string');
// 3. Check that generateFlyerIcon was called correctly
expect(generateFlyerIcon).toHaveBeenCalledWith('/uploads/flyer-page-1.jpg', '/uploads/icons');
expect(generateFlyerIcon).toHaveBeenCalledWith('/uploads/flyer-page-1.jpg', '/uploads/icons', mockLogger);
});
it('should handle missing optional data gracefully', async () => {
@@ -93,7 +98,7 @@ describe('FlyerDataTransformer', () => {
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-another.webp');
// Act
const { flyerData, itemsForDb } = await transformer.transform(extractedData, imagePaths, originalFileName, checksum);
const { flyerData, itemsForDb } = await transformer.transform(extractedData, imagePaths, originalFileName, checksum, undefined, mockLogger);
// Assert
expect(itemsForDb).toHaveLength(0);

View File

@@ -1,6 +1,7 @@
// src/services/flyerDataTransformer.ts
import path from 'path';
import type { z } from 'zod';
import type { Logger } from 'pino';
import type { FlyerInsert, FlyerItemInsert } from '../types';
import type { AiFlyerDataSchema } from './flyerProcessingService.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
@@ -17,6 +18,7 @@ export class FlyerDataTransformer {
* @param originalFileName The original name of the uploaded file.
* @param checksum The checksum of the file.
* @param userId The ID of the user who uploaded the file, if any.
* @param logger The request-scoped or job-scoped logger instance.
* @returns A promise that resolves to an object containing the prepared flyer and item data.
*/
async transform(
@@ -24,10 +26,11 @@ export class FlyerDataTransformer {
imagePaths: { path: string; mimetype: string }[],
originalFileName: string,
checksum: string,
userId?: string
userId: string | undefined,
logger: Logger
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
const firstImage = imagePaths[0].path;
const iconFileName = await generateFlyerIcon(firstImage, path.join(path.dirname(firstImage), 'icons'));
const iconFileName = await generateFlyerIcon(firstImage, path.join(path.dirname(firstImage), 'icons'), logger);
const itemsForDb: FlyerItemInsert[] = extractedData.items.map(item => ({
...item,

View File

@@ -66,7 +66,7 @@ vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'),
}));
vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis() }
}));
const mockedAiService = aiService as Mocked<typeof aiService>;
@@ -146,9 +146,10 @@ describe('FlyerProcessingService', () => {
describe('processJob (Orchestrator)', () => {
it('should process an image file successfully and enqueue a cleanup job', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
const result = await service.processJob(job);
const result = await service.processJob(job, logger);
expect(result).toEqual({ flyerId: 1 });
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
@@ -163,6 +164,7 @@ describe('FlyerProcessingService', () => {
});
it('should convert a PDF, process its images, and enqueue a cleanup job for all files', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({ filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf' });
// Mock readdir to return Dirent-like objects for the converted files
@@ -171,7 +173,7 @@ describe('FlyerProcessingService', () => {
{ name: 'flyer-2.jpg' },
] as Dirent[]);
await service.processJob(job);
await service.processJob(job, logger);
// Verify that pdftocairo was called
expect(mocks.execAsync).toHaveBeenCalledWith(
@@ -184,7 +186,8 @@ describe('FlyerProcessingService', () => {
expect.objectContaining({ path: expect.stringContaining('flyer-2.jpg') }),
]),
expect.any(Array),
undefined,
undefined, // submitterIp
undefined, // userProfileAddress
undefined
);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
@@ -197,11 +200,12 @@ describe('FlyerProcessingService', () => {
});
it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
const aiError = new Error('AI model exploded');
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
await expect(service.processJob(job, logger)).rejects.toThrow('AI model exploded');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
@@ -209,11 +213,12 @@ describe('FlyerProcessingService', () => {
it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => {
const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' });
const { logger } = await import('./logger.server');
const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error');
// Make the conversion step fail
mocks.execAsync.mockRejectedValue(conversionError);
await expect(service.processJob(job)).rejects.toThrow(conversionError);
await expect(service.processJob(job, logger)).rejects.toThrow(conversionError);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Conversion failed' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
@@ -221,34 +226,36 @@ describe('FlyerProcessingService', () => {
it('should throw AiDataValidationError and not enqueue cleanup if AI validation fails', async () => {
const job = createMockJob({});
const { logger: jobLogger } = await import('./logger.server');
const validationError = new AiDataValidationError('Validation failed', {}, {});
// Make the AI extraction step fail with a validation error
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(validationError);
await expect(service.processJob(job)).rejects.toThrow(validationError);
await expect(service.processJob(job, jobLogger)).rejects.toThrow(validationError);
// Verify the specific error handling logic in the catch block
const { logger } = await import('./logger.server');
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('AI Data Validation failed'), expect.any(Object));
expect(jobLogger.error).toHaveBeenCalledWith(expect.stringContaining('AI Data Validation failed'), expect.any(Object));
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Validation failed' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should throw an error if the database service fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const dbError = new Error('Database transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
await expect(service.processJob(job, logger)).rejects.toThrow('Database transaction failed');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
await expect(service.processJob(job)).rejects.toThrow();
await expect(service.processJob(job, logger)).rejects.toThrow();
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
});
@@ -278,6 +285,7 @@ describe('FlyerProcessingService', () => {
describe('_saveProcessedFlyerData (private method)', () => {
it('should transform data, create flyer in DB, and log activity', async () => {
const { logger } = await import('./logger.server');
// Arrange
const mockExtractedData = {
store_name: 'Test Store',
@@ -302,11 +310,11 @@ describe('FlyerProcessingService', () => {
vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] } as any);
// Act: Access and call the private method for testing
const result = await (service as any)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData);
const result = await (service as any)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData, logger);
// Assert
// 1. Transformer was called correctly
expect(transformerSpy).toHaveBeenCalledWith(mockExtractedData, mockImagePaths, mockJobData.originalFileName, mockJobData.checksum, mockJobData.userId);
expect(transformerSpy).toHaveBeenCalledWith(mockExtractedData, mockImagePaths, mockJobData.originalFileName, mockJobData.checksum, mockJobData.userId, logger);
// 2. DB function was called with the transformed data
const transformedData = await transformerSpy.mock.results[0].value;

View File

@@ -1,6 +1,7 @@
// src/services/flyerProcessingService.server.ts
import type { Job, JobsOptions } from 'bullmq';
import path from 'path';
import type { Logger } from 'pino';
import type { Dirent } from 'node:fs';
import { z } from 'zod';
@@ -96,8 +97,8 @@ export class FlyerProcessingService {
logger.info(`[Worker] Executing PDF conversion command: ${command}`);
const { stdout, stderr } = await this.exec(command);
if (stdout) logger.debug(`[Worker] pdftocairo stdout for ${filePath}:`, { stdout });
if (stderr) logger.warn(`[Worker] pdftocairo stderr for ${filePath}:`, { stderr });
if (stdout) logger.debug({ stdout }, `[Worker] pdftocairo stdout for ${filePath}:`);
if (stderr) logger.warn({ stderr }, `[Worker] pdftocairo stderr for ${filePath}:`);
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
@@ -107,13 +108,11 @@ export class FlyerProcessingService {
.filter(f => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
logger.debug(`[Worker] Filtered down to ${generatedImages.length} generated JPGs.`, {
imageNames: generatedImages.map(f => f.name),
});
logger.debug({ imageNames: generatedImages.map(f => f.name) }, `[Worker] Filtered down to ${generatedImages.length} generated JPGs.`);
if (generatedImages.length === 0) {
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
logger.error(`[Worker] PdfConversionError: ${errorMessage}`, { stderr });
logger.error({ stderr }, `[Worker] PdfConversionError: ${errorMessage}`);
throw new PdfConversionError(errorMessage, stderr);
}
@@ -147,26 +146,23 @@ export class FlyerProcessingService {
* @param jobData The data from the BullMQ job.
* @returns A promise that resolves to the validated, structured flyer data.
*/
private async _extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData) {
private async _extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData, logger: Logger) {
logger.info(`[Worker] Starting AI data extraction for job ${jobData.checksum}.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.database.personalizationRepo.getAllMasterItems();
const masterItems = await this.database.personalizationRepo.getAllMasterItems(logger);
logger.debug(`[Worker] Retrieved ${masterItems.length} master items for AI matching.`);
const extractedData = await this.ai.extractCoreDataFromFlyerImage(
imagePaths,
masterItems,
submitterIp,
userProfileAddress
userProfileAddress,
logger
);
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error('[Worker] AI response failed validation.', {
errors,
rawData: extractedData,
});
const errors = validationResult.error.flatten(); logger.error({ errors, rawData: extractedData }, '[Worker] AI response failed validation.');
throw new AiDataValidationError('AI response validation failed. The returned data structure is incorrect.', errors, extractedData);
}
@@ -184,7 +180,8 @@ export class FlyerProcessingService {
private async _saveProcessedFlyerData(
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData
jobData: FlyerJobData,
logger: Logger
) {
logger.info(`[Worker] Preparing to save extracted data to database for job ${jobData.checksum}.`);
@@ -194,14 +191,16 @@ export class FlyerProcessingService {
imagePaths,
jobData.originalFileName,
jobData.checksum,
jobData.userId
jobData.userId,
// Pass the job-specific logger to the transformer
logger
);
// 2. Save the transformed data to the database.
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb);
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb, logger);
logger.info(`[Worker] Successfully saved new flyer ID: ${newFlyer.flyer_id}`);
await this.database.adminRepo.logActivity({ userId: jobData.userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } });
await this.database.adminRepo.logActivity({ userId: jobData.userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } }, logger);
return newFlyer;
}
@@ -222,7 +221,7 @@ export class FlyerProcessingService {
}
async processJob(job: Job<FlyerJobData>) {
async processJob(job: Job<FlyerJobData>, logger: Logger) {
const { filePath, originalFileName } = job.data;
const createdImagePaths: string[] = [];
let newFlyerId: number | undefined;
@@ -235,10 +234,10 @@ export class FlyerProcessingService {
createdImagePaths.push(...tempImagePaths);
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data);
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data, logger);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this._saveProcessedFlyerData(extractedData, imagePaths, job.data);
const newFlyer = await this._saveProcessedFlyerData(extractedData, imagePaths, job.data, logger);
newFlyerId = newFlyer.flyer_id;
logger.info(`[Worker] Job ${job.id} for ${originalFileName} processed successfully. Flyer ID: ${newFlyerId}`);
@@ -247,24 +246,13 @@ export class FlyerProcessingService {
let errorMessage = 'An unknown error occurred';
if (error instanceof PdfConversionError) {
errorMessage = error.message;
logger.error(`[Worker] PDF Conversion failed for job ${job.id}.`, {
error: errorMessage,
stderr: error.stderr,
jobData: job.data,
});
logger.error({ err: error, stderr: error.stderr, jobData: job.data }, `[Worker] PDF Conversion failed for job ${job.id}.`);
} else if (error instanceof AiDataValidationError) {
errorMessage = error.message;
logger.error(`[Worker] AI Data Validation failed for job ${job.id}.`, {
error: errorMessage,
validationErrors: error.validationErrors,
rawData: error.rawData,
jobData: job.data,
});
logger.error({ err: error, validationErrors: error.validationErrors, rawData: error.rawData, jobData: job.data }, `[Worker] AI Data Validation failed for job ${job.id}.`);
} else if (error instanceof Error) {
errorMessage = error.message;
logger.error(`[Worker] A generic error occurred in job ${job.id}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: errorMessage, stack: error.stack, jobData: job.data,
});
logger.error({ err: error, jobData: job.data }, `[Worker] A generic error occurred in job ${job.id}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`);
}
await job.updateProgress({ message: `Error: ${errorMessage}` });
throw error;

View File

@@ -1,27 +1,27 @@
// src/services/geocodingService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeocodingService } from './geocodingService.server';
import { GoogleGeocodingService } from './googleGeocodingService.server';
import { NominatimGeocodingService } from './nominatimGeocodingService.server';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => ({
// Mock the redis connection object from queueService
mockRedis: {
get: vi.fn(),
set: vi.fn(),
scan: vi.fn(),
del: vi.fn(),
},
// Mock the fallback geocoding service
mockGeocodeWithNominatim: vi.fn(),
}));
// --- Mock Modules ---
vi.mock('./queueService.server', () => ({
connection: mocks.mockRedis,
}));
vi.mock('./nominatimGeocodingService.server', () => ({
geocodeWithNominatim: mocks.mockGeocodeWithNominatim,
}));
vi.mock('./googleGeocodingService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('./googleGeocodingService.server')>();
return { ...actual, GoogleGeocodingService: vi.fn() };
});
vi.mock('./logger.server', () => ({
logger: {
@@ -32,19 +32,24 @@ vi.mock('./logger.server', () => ({
},
}));
// Import the service to be tested AFTER all mocks are set up
import { geocodeAddress, clearGeocodeCache } from './geocodingService.server';
import { logger } from './logger.server';
describe('Geocoding Service', () => {
const originalEnv = process.env;
let geocodingService: GeocodingService;
let mockGoogleService: GoogleGeocodingService;
let mockNominatimService: NominatimGeocodingService;
beforeEach(() => {
vi.clearAllMocks();
// Mock the global fetch function
vi.stubGlobal('fetch', vi.fn());
// Restore process.env to its original state before each test
process.env = { ...originalEnv };
// Create a mock instance of the Google service
mockGoogleService = { geocode: vi.fn() } as unknown as GoogleGeocodingService;
// Create a mock instance of the dependency and the service under test
mockNominatimService = { geocode: vi.fn() } as unknown as NominatimGeocodingService;
geocodingService = new GeocodingService(mockGoogleService, mockNominatimService);
});
afterEach(() => {
@@ -61,89 +66,77 @@ describe('Geocoding Service', () => {
// Arrange: Mock Redis to return a cached result
mocks.mockRedis.get.mockResolvedValue(JSON.stringify(coordinates));
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
expect(fetch).not.toHaveBeenCalled();
expect(mocks.mockGeocodeWithNominatim).not.toHaveBeenCalled();
expect(mockGoogleService.geocode).not.toHaveBeenCalled();
expect(mockNominatimService.geocode).not.toHaveBeenCalled();
});
it('should log an error but continue if Redis GET fails', async () => {
// Arrange: Mock Redis 'get' to fail, but Google API to succeed
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockRejectedValue(new Error('Redis down'));
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }),
} as Response);
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Redis GET or JSON.parse command failed'), expect.any(Object));
expect(fetch).toHaveBeenCalled(); // Should still proceed to fetch
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error), cacheKey: expect.any(String) }, 'Redis GET or JSON.parse command failed. Proceeding without cache.');
expect(mockGoogleService.geocode).toHaveBeenCalled(); // Should still proceed to fetch
});
it('should proceed to fetch if cached data is invalid JSON', async () => {
// Arrange: Mock Redis to return a malformed JSON string
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue('{ "lat": 45.0, "lng": -75.0 '); // Missing closing brace
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }),
} as Response);
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
// The service should log the JSON parsing error and continue
expect(logger.error).toHaveBeenCalledWith( // This is now the expected behavior
expect.stringContaining('Redis GET or JSON.parse command failed'), expect.any(Object)
);
expect(fetch).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(SyntaxError), cacheKey: expect.any(String) }, 'Redis GET or JSON.parse command failed. Proceeding without cache.');
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
});
it('should fetch from Google, return coordinates, and cache the result on cache miss', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }),
} as Response);
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('maps.googleapis.com'));
expect(mockGoogleService.geocode).toHaveBeenCalledWith(address, logger);
expect(mocks.mockRedis.set).toHaveBeenCalledWith(cacheKey, JSON.stringify(coordinates), 'EX', expect.any(Number));
expect(mocks.mockGeocodeWithNominatim).not.toHaveBeenCalled();
expect(mockNominatimService.geocode).not.toHaveBeenCalled();
});
it('should fall back to Nominatim if Google API key is missing', async () => {
// Arrange
delete process.env.GOOGLE_MAPS_API_KEY;
mocks.mockRedis.get.mockResolvedValue(null);
mocks.mockGeocodeWithNominatim.mockResolvedValue(coordinates);
vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('GOOGLE_MAPS_API_KEY is not set'));
expect(fetch).not.toHaveBeenCalled();
expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address);
expect(mockGoogleService.geocode).not.toHaveBeenCalled();
expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
expect(mocks.mockRedis.set).toHaveBeenCalled();
});
@@ -151,50 +144,47 @@ describe('Geocoding Service', () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'ZERO_RESULTS', results: [] }),
} as Response);
mocks.mockGeocodeWithNominatim.mockResolvedValue(coordinates);
vi.mocked(mockGoogleService.geocode).mockResolvedValue(null); // Google returns no results
vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Falling back to Nominatim'));
expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address);
expect(logger.info).toHaveBeenCalledWith({ address: expect.any(String), provider: 'Google' }, expect.stringContaining('Falling back to Nominatim'));
expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
});
it('should fall back to Nominatim if Google API fetch call fails', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(fetch).mockRejectedValue(new Error('Network Error'));
mocks.mockGeocodeWithNominatim.mockResolvedValue(coordinates);
vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error'));
vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'), expect.any(Object));
expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address);
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'));
expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
});
it('should return null if both Google and Nominatim fail', async () => {
it('should return null and log an error if both Google and Nominatim fail', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(fetch).mockRejectedValue(new Error('Network Error'));
mocks.mockGeocodeWithNominatim.mockResolvedValue(null); // Nominatim also fails
vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error'));
vi.mocked(mockNominatimService.geocode).mockResolvedValue(null); // Nominatim also fails
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('All geocoding providers failed'));
expect(logger.error).toHaveBeenCalledWith({ address }, 'All geocoding providers failed.');
expect(mocks.mockRedis.set).not.toHaveBeenCalled(); // Should not cache a null result
});
@@ -202,21 +192,18 @@ describe('Geocoding Service', () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }),
} as Response);
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
// Mock Redis 'set' to fail
mocks.mockRedis.set.mockRejectedValue(new Error('Redis SET failed'));
// Act
const result = await geocodeAddress(address);
const result = await geocodingService.geocodeAddress(address, logger);
// Assert
expect(result).toEqual(coordinates); // The result should still be returned to the caller
expect(fetch).toHaveBeenCalledTimes(1);
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Redis SET command failed'), expect.any(Object));
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error), cacheKey: expect.any(String) }, 'Redis SET command failed. Result will not be cached.');
});
});
@@ -233,7 +220,7 @@ describe('Geocoding Service', () => {
.mockResolvedValueOnce(1);
// Act
const result = await clearGeocodeCache();
const result = await geocodingService.clearGeocodeCache(logger);
// Assert
expect(result).toBe(3); // 2 + 1
@@ -248,7 +235,7 @@ describe('Geocoding Service', () => {
mocks.mockRedis.scan.mockResolvedValueOnce(['0', []]);
// Act
const result = await clearGeocodeCache();
const result = await geocodingService.clearGeocodeCache(logger);
// Assert
expect(result).toBe(0);

View File

@@ -1,145 +1,83 @@
// src/services/geocodingService.server.ts
import { logger } from './logger.server';
import { geocodeWithNominatim } from './nominatimGeocodingService.server';
import { connection as redis } from './queueService.server'; // Import the configured Redis connection
import type { Logger } from 'pino';
import { connection as redis } from './queueService.server';
import { googleGeocodingService, GoogleGeocodingService } from './googleGeocodingService.server';
import { nominatimGeocodingService, NominatimGeocodingService } from './nominatimGeocodingService.server';
const REDIS_CACHE_EXPIRATION_SECONDS = 60 * 60 * 24 * 30; // 30 days
const GEOCODE_CACHE_PREFIX = 'geocode:';
export class GeocodingService {
constructor(
private googleService: GoogleGeocodingService,
private nominatimService: NominatimGeocodingService
) {}
// --- Private Helper Functions ---
async geocodeAddress(address: string, logger: Logger): Promise<{ lat: number; lng: number } | null> {
const cacheKey = `geocode:${address}`;
/**
* Tries to retrieve and parse geocoding data from the Redis cache.
* @param cacheKey The key for the cached data.
* @returns The parsed coordinates or null if not found or on error.
*/
async function getFromCache(cacheKey: string): Promise<{ lat: number; lng: number } | null> {
try {
const cachedResult = await redis.get(cacheKey);
if (cachedResult) {
logger.info(`[GeocodingService] Redis cache hit for key: "${cacheKey}"`);
return JSON.parse(cachedResult);
const cached = await redis.get(cacheKey);
if (cached) {
logger.info({ cacheKey }, 'Geocoding result found in cache.');
return JSON.parse(cached);
}
} catch (error) {
logger.error({ err: error, cacheKey }, 'Redis GET or JSON.parse command failed. Proceeding without cache.');
}
if (process.env.GOOGLE_MAPS_API_KEY) {
try {
const coordinates = await this.googleService.geocode(address, logger);
if (coordinates) {
await this.setCache(cacheKey, coordinates, logger);
return coordinates;
}
return null;
} catch (error) {
logger.error('[GeocodingService] Redis GET or JSON.parse command failed. Proceeding without cache.', { error, cacheKey });
return null;
logger.info({ address, provider: 'Google' }, 'Google Geocoding returned no results. Falling back to Nominatim.');
} catch (error) {
logger.error({ err: error }, 'An error occurred while calling the Google Maps Geocoding API. Falling back to Nominatim.');
}
} else {
logger.warn('GOOGLE_MAPS_API_KEY is not set. Falling back to Nominatim as the primary geocoding provider.');
}
}
/**
* Caches the geocoding result in Redis.
* @param cacheKey The key to store the data under.
* @param coordinates The coordinates to cache.
*/
async function setCache(cacheKey: string, coordinates: { lat: number; lng: number }): Promise<void> {
const nominatimResult = await this.nominatimService.geocode(address, logger);
if (nominatimResult) {
await this.setCache(cacheKey, nominatimResult, logger);
return nominatimResult;
}
logger.error({ address }, 'All geocoding providers failed.');
return null;
}
private async setCache(cacheKey: string, result: { lat: number; lng: number }, logger: Logger): Promise<void> {
try {
await redis.set(cacheKey, JSON.stringify(coordinates), 'EX', REDIS_CACHE_EXPIRATION_SECONDS);
logger.info(`[GeocodingService] Successfully cached result for key: "${cacheKey}"`);
await redis.set(cacheKey, JSON.stringify(result), 'EX', 60 * 60 * 24 * 30); // Cache for 30 days
} catch (error) {
logger.error('[GeocodingService] Redis SET command failed. Result will not be cached.', { error, cacheKey });
logger.error({ err: error, cacheKey }, 'Redis SET command failed. Result will not be cached.');
}
}
/**
* Geocodes an address using the Google Maps API.
* @param address The address string to geocode.
* @returns A promise that resolves to coordinates or null if not found/failed.
*/
async function geocodeWithGoogle(address: string): Promise<{ lat: number; lng: number } | null> {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
logger.warn('[GeocodingService] GOOGLE_MAPS_API_KEY is not set. Cannot use Google Geocoding.');
return null;
}
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
const response = await fetch(url);
const data = await response.json();
if (data.status !== 'OK' || !data.results || data.results.length === 0) return null;
return data.results[0].geometry.location; // { lat, lng }
}
/**
* Geocodes a physical address into latitude and longitude coordinates.
* It first attempts to use the Google Maps API. If that is unavailable or fails,
* it falls back to the free OpenStreetMap Nominatim API.
* Results are cached in Redis to avoid redundant API calls.
* @param address The address string to geocode.
* @returns A promise that resolves to an object with latitude and longitude, or null if not found.
*/
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
const cacheKey = `${GEOCODE_CACHE_PREFIX}${address}`;
// 1. Check Redis cache first.
const cachedCoordinates = await getFromCache(cacheKey);
if (cachedCoordinates) {
return cachedCoordinates;
}
logger.info(`[GeocodingService] Redis cache miss for address: "${address}". Fetching from API.`);
async clearGeocodeCache(logger: Logger): Promise<number> {
let cursor = '0';
let totalDeleted = 0;
const pattern = 'geocode:*';
logger.info(`Starting geocode cache clear with pattern: ${pattern}`);
let coordinates: { lat: number; lng: number } | null = null;
// 2. Try primary provider (Google).
try {
logger.info(`[GeocodingService] Attempting geocoding with Google for address: "${address}"`);
coordinates = await geocodeWithGoogle(address);
} catch (error) {
logger.error('[GeocodingService] An error occurred while calling the Google Maps Geocoding API.', { error });
// Fall through to the fallback provider.
}
// 3. If primary provider fails, try fallback provider (Nominatim).
if (!coordinates) {
try {
logger.info('[GeocodingService] Falling back to Nominatim due to Google API failure or missing key.');
coordinates = await geocodeWithNominatim(address);
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
const deletedCount = await redis.del(keys);
totalDeleted += deletedCount;
}
} while (cursor !== '0');
logger.info(`Successfully deleted ${totalDeleted} geocode cache entries.`);
return totalDeleted;
} catch (error) {
logger.error('[GeocodingService] An error occurred while calling the Nominatim API.', { error });
logger.error({ err: error }, 'Failed to clear geocode cache from Redis.');
throw error;
}
}
// 4. If a result was found, cache it and return.
if (coordinates) {
logger.info(`[GeocodingService] Successfully geocoded address: "${address}"`, { coordinates });
await setCache(cacheKey, coordinates);
return coordinates;
}
// 5. If all providers fail, return null.
logger.warn(`[GeocodingService] All geocoding providers failed for address: "${address}"`);
return null;
}
/**
* Clears all geocoding entries from the Redis cache.
* This function iterates through all keys matching the 'geocode:*' pattern and deletes them.
* It uses the SCAN command to avoid blocking the Redis server.
* @returns The number of keys that were deleted.
*/
export async function clearGeocodeCache(): Promise<number> {
let cursor = '0';
let keysDeleted = 0;
const pattern = `${GEOCODE_CACHE_PREFIX}*`;
logger.info('[GeocodingService] Starting to clear geocode cache...');
do {
// Scan for keys matching the pattern in batches of 100.
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
if (keys.length > 0) {
const count = await redis.del(keys);
keysDeleted += count;
logger.debug(`[GeocodingService] Deleted ${count} keys from cache.`);
}
cursor = newCursor;
} while (cursor !== '0');
logger.info(`[GeocodingService] Finished clearing geocode cache. Total keys deleted: ${keysDeleted}`);
return keysDeleted;
}
export const geocodingService = new GeocodingService(googleGeocodingService, nominatimGeocodingService);

View File

@@ -0,0 +1,44 @@
// src/services/googleGeocodingService.server.ts
import type { Logger } from 'pino';
import { logger as defaultLogger } from './logger.server';
export class GoogleGeocodingService {
private readonly baseUrl = 'https://maps.googleapis.com/maps/api/geocode/json';
/**
* Geocodes an address using the Google Maps Geocoding API.
* @param address The address string to geocode.
* @param logger A logger instance.
* @returns A promise that resolves to the coordinates or null if not found.
*/
async geocode(address: string, logger: Logger = defaultLogger): Promise<{ lat: number; lng: number } | null> {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
logger.error('[GoogleGeocodingService] API key is missing.');
throw new Error('GOOGLE_MAPS_API_KEY is not set.');
}
const url = `${this.baseUrl}?address=${encodeURIComponent(address)}&key=${apiKey}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Google Maps API returned status ${response.status}`);
}
const data = await response.json();
if (data.status === 'OK' && data.results.length > 0) {
logger.info({ address, result: data.results[0].geometry.location }, `[GoogleGeocodingService] Successfully geocoded address`);
return data.results[0].geometry.location;
}
logger.warn({ address, status: data.status }, '[GoogleGeocodingService] Geocoding failed or returned no results.');
return null;
} catch (error) {
logger.error({ err: error, address }, '[GoogleGeocodingService] An error occurred while calling the Google Maps API.');
throw error; // Re-throw to allow the calling service to handle the failure (e.g., by falling back).
}
}
}
export const googleGeocodingService = new GoogleGeocodingService();

View File

@@ -1,66 +1,29 @@
// src/services/logger.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger } from './logger.server';
// Mock pino before importing the logger
const pinoMock = vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}));
vi.mock('pino', () => ({ default: pinoMock }));
describe('Server Logger', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let spies: any;
beforeEach(() => {
// Clear any previous calls before each test
vi.clearAllMocks();
// Create fresh spies for each test on the global console object
spies = {
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
};
});
afterEach(() => {
// Restore all spies
vi.restoreAllMocks();
it('should initialize pino with the correct level for production', async () => {
process.env.NODE_ENV = 'production';
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
});
it('logger.info should format the message correctly with timestamp, PID, and [INFO] prefix', () => {
const message = 'This is a server info message';
const data = { id: 1, name: 'test' };
logger.info(message, data);
expect(spies.log).toHaveBeenCalledTimes(1);
// Use stringMatching for the dynamic parts (timestamp, PID)
expect(spies.log).toHaveBeenCalledWith(
expect.stringMatching(/\[.+\] \[PID:\d+\] \[INFO\] This is a server info message/),
data
);
});
it('logger.warn should format the message correctly with timestamp, PID, and [WARN] prefix', () => {
const message = 'This is a server warning';
logger.warn(message);
expect(spies.warn).toHaveBeenCalledTimes(1);
expect(spies.warn).toHaveBeenCalledWith(
expect.stringMatching(/\[.+\] \[PID:\d+\] \[WARN\] This is a server warning/)
);
});
it('logger.error should format the message correctly with timestamp, PID, and [ERROR] prefix', () => {
const message = 'A server error occurred';
const error = new Error('Test Server Error');
logger.error(message, error);
expect(spies.error).toHaveBeenCalledTimes(1);
expect(spies.error).toHaveBeenCalledWith(expect.stringMatching(/\[.+\] \[PID:\d+\] \[ERROR\] A server error occurred/), error);
});
it('logger.debug should format the message correctly with timestamp, PID, and [DEBUG] prefix', () => {
const message = 'Debugging server data';
logger.debug(message, { key: 'value' });
expect(spies.debug).toHaveBeenCalledTimes(1);
expect(spies.debug).toHaveBeenCalledWith(expect.stringMatching(/\[.+\] \[PID:\d+\] \[DEBUG\] Debugging server data/), { key: 'value' });
it('should initialize pino with pretty-print transport for development', async () => {
process.env.NODE_ENV = 'development';
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ transport: expect.any(Object) }));
});
});

View File

@@ -1,47 +1,22 @@
// src/services/logger.server.ts
/**
* SERVER-SIDE LOGGER
* A logger service that includes server-specific details like process ID.
* This file should only be imported in Node.js environments.
* such as adding timestamps, log levels, or sending logs to a remote service.
* This file configures and exports a singleton `pino` logger instance for
* server-side use, adhering to ADR-004 for structured JSON logging.
*/
import pino from 'pino';
const getTimestamp = () => new Date().toISOString();
const isProduction = process.env.NODE_ENV === 'production';
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
/**
* The core logging function. It uses a generic rest parameter `<T extends any[]>`
* to safely accept any number of arguments of any type, just like the native console
* methods. Using `unknown[]` is more type-safe than `any[]` and satisfies the linter.
*/
const log = <T extends unknown[]>(level: LogLevel, message: string, ...args: T) => {
const pid = process.pid;
const timestamp = getTimestamp();
// We construct the log message with a timestamp, PID, and level for better context.
const logMessage = `[${timestamp}] [PID:${pid}] [${level}] ${message}`;
switch (level) {
case 'INFO':
console.log(logMessage, ...args);
break;
case 'WARN':
console.warn(logMessage, ...args);
break;
case 'ERROR':
console.error(logMessage, ...args);
break;
case 'DEBUG':
// For now, we can show debug logs in development. This could be controlled by an environment variable.
console.debug(logMessage, ...args);
break;
}
};
// Export the logger object for use throughout the application.
export const logger = {
info: <T extends unknown[]>(message: string, ...args: T) => log('INFO', message, ...args),
warn: <T extends unknown[]>(message: string, ...args: T) => log('WARN', message, ...args),
error: <T extends unknown[]>(message: string, ...args: T) => log('ERROR', message, ...args),
debug: <T extends unknown[]>(message: string, ...args: T) => log('DEBUG', message, ...args),
};
export const logger = pino({
level: isProduction ? 'info' : 'debug',
// Use pino-pretty for human-readable logs in development, and JSON in production.
transport: isProduction ? undefined : {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname', // These are useful in production, but noisy in dev.
},
},
});

View File

@@ -1,6 +1,8 @@
// src/services/nominatimGeocodingService.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.unmock('./nominatimGeocodingService.server');
// Mock the logger to prevent console output and allow for assertions
vi.mock('./logger.server', () => ({
logger: {
@@ -12,8 +14,8 @@ vi.mock('./logger.server', () => ({
}));
// Import the function to be tested after setting up mocks
import { geocodeWithNominatim } from './nominatimGeocodingService.server';
import { logger } from './logger.server';
import { nominatimGeocodingService } from './nominatimGeocodingService.server';
import { logger as mockLogger } from './logger.server';
describe('Nominatim Geocoding Service', () => {
beforeEach(() => {
@@ -35,12 +37,12 @@ describe('Nominatim Geocoding Service', () => {
} as Response);
// Act
const result = await geocodeWithNominatim('Victoria, BC');
const result = await nominatimGeocodingService.geocode('Victoria, BC', mockLogger);
// Assert
expect(result).toEqual({ lat: 48.4284, lng: -123.3656 });
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully geocoded address'), expect.any(Object));
expect(logger.error).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith({ address: 'Victoria, BC', result: { lat: '48.4284', lon: '-123.3656' } }, `[NominatimService] Successfully geocoded address`);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should return null if the API returns no results', async () => {
@@ -51,12 +53,12 @@ describe('Nominatim Geocoding Service', () => {
} as Response);
// Act
const result = await geocodeWithNominatim('NonExistentPlace 12345');
const result = await nominatimGeocodingService.geocode('NonExistentPlace 12345', mockLogger);
// Assert
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith('[NominatimService] Geocoding failed or returned no results.', { address: 'NonExistentPlace 12345' });
expect(logger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith({ address: 'NonExistentPlace 12345' }, '[NominatimService] Geocoding failed or returned no results.');
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should return null and log an error if the fetch call fails', async () => {
@@ -65,10 +67,10 @@ describe('Nominatim Geocoding Service', () => {
vi.mocked(fetch).mockRejectedValue(networkError);
// Act
const result = await geocodeWithNominatim('Any Address');
const result = await nominatimGeocodingService.geocode('Any Address', mockLogger);
// Assert
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith('[NominatimService] An error occurred while calling the Nominatim API.', { error: networkError });
expect(mockLogger.error).toHaveBeenCalledWith({ err: networkError, address: 'Any Address' }, '[NominatimService] An error occurred while calling the Nominatim API.');
});
});

View File

@@ -1,38 +1,33 @@
// src/services/nominatimGeocodingService.server.ts
import { logger } from './logger.server';
import type { Logger } from 'pino';
import { logger as defaultLogger } from './logger.server';
/**
* Geocodes a physical address using the public OpenStreetMap Nominatim API.
* This serves as a free fallback to the Google Maps Geocoding API.
* @param address The address string to geocode.
* @returns A promise that resolves to an object with latitude and longitude, or null if not found.
*/
export async function geocodeWithNominatim(address: string): Promise<{ lat: number; lng: number } | null> {
// Nominatim requires a specific User-Agent header.
const headers = new Headers({
'User-Agent': 'FlyerCrawler/1.0 (flyer-crawler.projectium.com)',
});
export class NominatimGeocodingService {
/**
* Geocodes an address using the public Nominatim API.
* @param address The address string to geocode.
* @param logger A logger instance.
* @returns A promise that resolves to the coordinates or null if not found.
*/
async geocode(address: string, logger: Logger = defaultLogger): Promise<{ lat: number; lng: number } | null> {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`;
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`;
try {
const response = await fetch(url, { headers: { 'User-Agent': 'FlyerCrawler/1.0' } });
if (!response.ok) throw new Error(`Nominatim API returned status ${response.status}`);
try {
logger.info(`[NominatimService] Attempting geocoding for address: "${address}"`);
const response = await fetch(url, { headers });
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
logger.warn('[NominatimService] Geocoding failed or returned no results.', { address });
const data = await response.json();
if (data && data.length > 0) {
const { lat, lon } = data[0];
logger.info({ address, result: { lat, lon } }, `[NominatimService] Successfully geocoded address`);
return { lat: parseFloat(lat), lng: parseFloat(lon) };
}
logger.warn({ address }, '[NominatimService] Geocoding failed or returned no results.');
return null;
} catch (error) {
logger.error({ err: error, address }, '[NominatimService] An error occurred while calling the Nominatim API.');
return null;
}
const location = data[0];
logger.info(`[NominatimService] Successfully geocoded address: "${address}"`, { location });
return {
lat: parseFloat(location.lat),
lng: parseFloat(location.lon),
};
} catch (error) {
logger.error('[NominatimService] An error occurred while calling the Nominatim API.', { error });
return null;
}
}
}
export const nominatimGeocodingService = new NominatimGeocodingService();

View File

@@ -1,62 +0,0 @@
// src/services/notificationService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { notifySuccess, notifyError } from './notificationService';
import toast from 'react-hot-toast';
// Mock react-hot-toast
vi.mock('react-hot-toast', () => {
const toastFn = vi.fn() as any;
toastFn.success = vi.fn();
toastFn.error = vi.fn();
return {
default: toastFn,
__esModule: true,
};
});
// describe('notificationService', () => {
// beforeEach(() => {
// vi.clearAllMocks();
// });
//
// describe('notifySuccess', () => {
// // it('should call toast.success with the correct message and options', () => {
// // const message = 'Operation completed successfully!';
// // console.log('[TEST] Invoking notifySuccess...');
// // notifySuccess(message);
// //
// // // Access the mock directly via the import
// // expect(toast.success).toHaveBeenCalledTimes(1);
// // expect(toast.success).toHaveBeenCalledWith(
// // message,
// // expect.objectContaining({
// // style: expect.any(Object),
// // iconTheme: {
// // primary: '#10B981',
// // secondary: '#fff',
// // },
// // })
// // );
// // });
// });
//
// describe('notifyError', () => {
// // it('should call toast.error with the correct message and options', () => {
// // const message = 'An unexpected error occurred.';
// // console.log('[TEST] Invoking notifyError...');
// notifyError(message);
//
// expect(toast.error).toHaveBeenCalledTimes(1);
// expect(toast.error).toHaveBeenCalledWith(
// message,
// expect.objectContaining({
// style: expect.any(Object),
// iconTheme: {
// primary: '#EF4444',
// secondary: '#fff',
// },
// })
// );
// });
// });
// });

View File

@@ -30,4 +30,13 @@ export class AiDataValidationError extends FlyerProcessingError {
constructor(message: string, public validationErrors: object, public rawData: unknown) {
super(message);
}
}
/**
* Error thrown when all geocoding providers fail to find coordinates for an address.
*/
export class GeocodingFailedError extends FlyerProcessingError {
constructor(message: string) {
super(message);
}
}

View File

@@ -31,8 +31,8 @@ connection.on('connect', () => {
});
connection.on('error', (err) => {
// This is crucial for diagnosing Redis connection issues.
logger.error('[Redis] Connection error.', { error: err });
// This is crucial for diagnosing Redis connection issues. // The patch requested this specific error handling.
logger.error({ err }, '[Redis] Connection error.');
});
const execAsync = promisify(exec);
@@ -152,24 +152,27 @@ const flyerProcessingService = new FlyerProcessingService(
*/
const attachWorkerEventListeners = (worker: Worker) => {
worker.on('completed', (job: Job, returnValue: unknown) => {
logger.info(`[${worker.name}] Job ${job.id} completed successfully.`, { returnValue });
logger.info({ returnValue }, `[${worker.name}] Job ${job.id} completed successfully.`);
});
worker.on('failed', (job: Job | undefined, error: Error) => {
// This event fires after all retries have failed.
logger.error(`[${worker.name}] Job ${job?.id} has ultimately failed after all attempts.`, { error: error.message, stack: error.stack, jobData: job?.data });
logger.error({ err: error, jobData: job?.data }, `[${worker.name}] Job ${job?.id} has ultimately failed after all attempts.`);
});
};
export const flyerWorker = new Worker<FlyerJobData>(
'flyer-processing', // Must match the queue name
(job) => flyerProcessingService.processJob(job), // The processor function
(job) => {
// Create a job-specific logger instance
const jobLogger = logger.child({ jobId: job.id, jobName: job.name, userId: job.data.userId });
return flyerProcessingService.processJob(job, jobLogger);
},
{
connection,
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '1', 10),
}
);
/**
* A dedicated worker process for sending emails.
*/
@@ -177,18 +180,20 @@ export const emailWorker = new Worker<EmailJobData>(
'email-sending',
async (job: Job<EmailJobData>) => {
const { to, subject } = job.data;
logger.info(`[EmailWorker] Sending email for job ${job.id}`, { to, subject });
// Create a job-specific logger instance
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
jobLogger.info({ to, subject }, `[EmailWorker] Sending email for job ${job.id}`);
try {
await emailService.sendEmail(job.data);
await emailService.sendEmail(job.data, jobLogger);
} catch (error: unknown) {
// Standardize error logging to capture the full error object, including the stack trace.
// This provides more context for debugging than just logging the message.
logger.error(`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
// Log the full error object for better diagnostics.
error: error instanceof Error ? error : new Error(String(error)),
logger.error({
// Log the full error object for better diagnostics. // The patch requested this specific error handling.
err: error instanceof Error ? error : new Error(String(error)),
// Also include the job data for context.
jobData: job.data,
});
}, `[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`);
// Re-throw to let BullMQ handle the failure and retry.
throw error;
}
@@ -207,7 +212,7 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
'analytics-reporting',
async (job: Job<AnalyticsJobData>) => {
const { reportDate } = job.data;
logger.info(`[AnalyticsWorker] Starting report generation for job ${job.id}`, { reportDate });
logger.info({ reportDate }, `[AnalyticsWorker] Starting report generation for job ${job.id}`);
try {
// Special case for testing the retry mechanism
if (reportDate === 'FAIL') {
@@ -220,10 +225,10 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
logger.info(`[AnalyticsWorker] Successfully generated report for ${reportDate}.`);
} catch (error: unknown) {
// Standardize error logging.
logger.error(`[AnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: error instanceof Error ? error : new Error(String(error)),
logger.error({
err: error instanceof Error ? error : new Error(String(error)),
jobData: job.data,
});
}, `[AnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`);
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
@@ -243,7 +248,7 @@ export const cleanupWorker = new Worker<CleanupJobData>(
async (job: Job<CleanupJobData>) => {
// Destructure the data from the job payload.
const { flyerId, paths } = job.data;
logger.info(`[CleanupWorker] Starting file cleanup for job ${job.id} (Flyer ID: ${flyerId})`, { paths });
logger.info({ paths }, `[CleanupWorker] Starting file cleanup for job ${job.id} (Flyer ID: ${flyerId})`);
try {
if (!paths || paths.length === 0) {
@@ -269,9 +274,9 @@ export const cleanupWorker = new Worker<CleanupJobData>(
logger.info(`[CleanupWorker] Successfully cleaned up ${paths.length} file(s) for flyer ${flyerId}.`);
} catch (error: unknown) {
// Standardize error logging.
logger.error(`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: error instanceof Error ? error : new Error(String(error)),
});
logger.error({
err: error instanceof Error ? error : new Error(String(error)),
}, `[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`);
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
@@ -289,17 +294,17 @@ export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
'weekly-analytics-reporting',
async (job: Job<WeeklyAnalyticsJobData>) => {
const { reportYear, reportWeek } = job.data;
logger.info(`[WeeklyAnalyticsWorker] Starting weekly report generation for job ${job.id}`, { reportYear, reportWeek });
logger.info({ reportYear, reportWeek }, `[WeeklyAnalyticsWorker] Starting weekly report generation for job ${job.id}`);
try {
// Simulate a longer-running task for weekly reports
await new Promise(resolve => setTimeout(resolve, 30000)); // Simulate 30-second task
logger.info(`[WeeklyAnalyticsWorker] Successfully generated weekly report for week ${reportWeek}, ${reportYear}.`);
} catch (error: unknown) {
// Standardize error logging.
logger.error(`[WeeklyAnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: error instanceof Error ? error : new Error(String(error)),
logger.error({
err: error instanceof Error ? error : new Error(String(error)),
jobData: job.data,
});
}, `[WeeklyAnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`);
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},

View File

@@ -39,6 +39,11 @@ vi.mock('./db/user.db', () => ({
UserRepository: mocks.MockUserRepository,
}));
vi.mock('./logger.server', () => ({
// Provide a default mock for the logger
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
// Import the service to be tested AFTER all mocks are set up.
import { userService } from './userService';
@@ -50,6 +55,7 @@ describe('UserService', () => {
describe('upsertUserAddress', () => {
it('should create a new address and link it to a user who has no address', async () => {
const { logger } = await import('./logger.server');
// Arrange: A user profile without an existing address_id.
const user: UserProfile = { user_id: 'user-123', address_id: null } as UserProfile;
const addressData: Partial<Address> = { address_line_1: '123 New St', city: 'Newville' };
@@ -59,7 +65,7 @@ describe('UserService', () => {
mocks.mockUpsertAddress.mockResolvedValue(newAddressId);
// Act: Call the service method.
const result = await userService.upsertUserAddress(user, addressData);
const result = await userService.upsertUserAddress(user, addressData, logger);
// Assert
expect(result).toBe(newAddressId);
@@ -69,13 +75,14 @@ describe('UserService', () => {
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({
...addressData,
address_id: undefined, // user.address_id was null, so it should be undefined.
});
}, logger);
// 3. Verify the user's profile was updated to link the new address ID.
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledTimes(1);
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith('user-123', { address_id: newAddressId });
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith('user-123', { address_id: newAddressId }, logger);
});
it('should update an existing address and NOT link it if the ID does not change', async () => {
const { logger } = await import('./logger.server');
// Arrange: A user profile with an existing address_id.
const existingAddressId = 42;
const user: UserProfile = { user_id: 'user-123', address_id: existingAddressId } as UserProfile;
@@ -85,7 +92,7 @@ describe('UserService', () => {
mocks.mockUpsertAddress.mockResolvedValue(existingAddressId);
// Act: Call the service method.
const result = await userService.upsertUserAddress(user, addressData);
const result = await userService.upsertUserAddress(user, addressData, logger);
// Assert
expect(result).toBe(existingAddressId);
@@ -95,7 +102,7 @@ describe('UserService', () => {
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({
...addressData,
address_id: existingAddressId,
});
}, logger);
// 3. Since the address ID did not change, the user profile should NOT be updated.
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
});

View File

@@ -1,5 +1,6 @@
// src/services/userService.ts
import * as db from './db/index.db';
import type { Logger } from 'pino';
import { AddressRepository } from './db/address.db';
import { UserRepository } from './db/user.db';
import type { Address, UserProfile } from '../types';
@@ -16,17 +17,17 @@ class UserService {
* @param addressData The address data to upsert.
* @returns The ID of the upserted address.
*/
async upsertUserAddress(user: UserProfile, addressData: Partial<Address>): Promise<number> {
async upsertUserAddress(user: UserProfile, addressData: Partial<Address>, logger: Logger): Promise<number> {
return db.withTransaction(async (client) => {
// Instantiate repositories with the transactional client
const addressRepo = new AddressRepository(client);
const userRepo = new UserRepository(client);
const addressId = await addressRepo.upsertAddress({ ...addressData, address_id: user.address_id ?? undefined });
const addressId = await addressRepo.upsertAddress({ ...addressData, address_id: user.address_id ?? undefined }, logger);
// If the user didn't have an address_id before, update their profile to link it.
if (!user.address_id) {
await userRepo.updateUserProfile(user.user_id, { address_id: addressId });
await userRepo.updateUserProfile(user.user_id, { address_id: addressId }, logger);
}
return addressId;

View File

@@ -1,7 +1,21 @@
// src/types/express.d.ts
import { Request, Response, NextFunction } from 'express';
import { Logger } from 'pino';
import * as qs from 'qs';
/**
* This file uses declaration merging to add a custom `log` property to the
* global Express Request interface. This makes the request-scoped logger
* available in a type-safe way in all route handlers, as required by ADR-004.
*/
declare global {
namespace Express {
export interface Request {
log: Logger;
}
}
}
/**
* Defines a more accurate type for asynchronous Express route handlers.
* Standard `RequestHandler` requires `next` to be present, which is often unused

View File

@@ -1,4 +1,6 @@
// src/utils/checksum.ts
import { logger } from '../services/logger.client';
/**
* Helper to read file as ArrayBuffer using FileReader (fallback for environments missing file.arrayBuffer)
*/
@@ -26,7 +28,7 @@ const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
*/
export const generateFileChecksum = async (file: File): Promise<string> => {
// Debugging: Log file details to ensure we are receiving what we expect
console.log(`generateFileChecksum processing file: name="${file.name}", type="${file.type}", size=${file.size}`);
logger.debug(`generateFileChecksum processing file: name="${file.name}", type="${file.type}", size=${file.size}`);
let buffer: ArrayBuffer;
@@ -35,17 +37,17 @@ export const generateFileChecksum = async (file: File): Promise<string> => {
try {
buffer = await file.arrayBuffer();
} catch (error) {
console.warn('file.arrayBuffer() threw an error, falling back to FileReader:', error);
logger.warn('file.arrayBuffer() threw an error, falling back to FileReader:', { error });
buffer = await readFileAsArrayBuffer(file);
}
} else {
console.warn('file.arrayBuffer is not a function on this File object. Using FileReader fallback.');
logger.warn('file.arrayBuffer is not a function on this File object. Using FileReader fallback.');
buffer = await readFileAsArrayBuffer(file);
}
// Debugging: Verify crypto availability
if (!crypto || !crypto.subtle) {
console.error('crypto.subtle is not available in this environment. SHA-256 generation will fail.');
logger.error('crypto.subtle is not available in this environment. SHA-256 generation will fail.');
}
// Ensure buffer is a TypedArray or ArrayBuffer that crypto.subtle accepts

View File

@@ -1,5 +1,6 @@
// src/utils/imageProcessor.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Logger } from 'pino';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
@@ -34,16 +35,25 @@ vi.mock('node:fs/promises', () => ({
},
}));
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
// Helper to create a type-safe mock logger
const createMockLogger = (): Logger => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn(() => createMockLogger()), // Return a new mock for child loggers
level: 'info',
} as unknown as Logger);
const logger = createMockLogger();
vi.mock('../services/logger.server', () => ({ logger }));
// --- Import the function to be tested ---
import { generateFlyerIcon } from './imageProcessor';
import { logger } from '../services/logger.server';
describe('generateFlyerIcon', () => {
beforeEach(() => {
@@ -54,7 +64,7 @@ describe('generateFlyerIcon', () => {
const sourceImagePath = '/path/to/flyer image (1).jpg';
const iconsDirectory = '/path/to/icons';
const result = await generateFlyerIcon(sourceImagePath, iconsDirectory);
const result = await generateFlyerIcon(sourceImagePath, iconsDirectory, logger);
// Check that the icons directory was created
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
@@ -75,7 +85,7 @@ describe('generateFlyerIcon', () => {
const sharpError = new Error('Invalid image buffer');
mocks.toFile.mockRejectedValue(sharpError);
await expect(generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons')).rejects.toThrow('Icon generation failed.');
expect(logger.error).toHaveBeenCalledWith('Failed to generate flyer icon:', expect.any(Object));
await expect(generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger)).rejects.toThrow('Icon generation failed.');
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), 'Failed to generate flyer icon:');
});
});

View File

@@ -1,8 +1,8 @@
// src/utils/imageProcessor.ts
import sharp from 'sharp';
import path from 'path';
import fs from 'node:fs/promises';
import { logger } from '../services/logger.server';
import fs from 'node:fs/promises';
import type { Logger } from 'pino';
import { sanitizeFilename } from './stringUtils';
/**
@@ -10,9 +10,10 @@ import { sanitizeFilename } from './stringUtils';
* @param sourceImagePath The full path to the original image file.
* @param iconsDirectory The directory where the icon should be saved.
* @returns A promise that resolves to the filename of the newly created icon.
* @param logger The request-scoped logger instance, as per ADR-004.
* @throws An error if the icon generation fails.
*/
export async function generateFlyerIcon(sourceImagePath: string, iconsDirectory: string): Promise<string> {
export async function generateFlyerIcon(sourceImagePath: string, iconsDirectory: string, logger: Logger): Promise<string> {
try {
// 1. Create a new filename, standardizing the extension to .webp for consistency and performance.
// We sanitize the original filename to remove spaces and special characters, ensuring URL safety.
@@ -40,13 +41,7 @@ export async function generateFlyerIcon(sourceImagePath: string, iconsDirectory:
logger.info(`Generated 64x64 icon: ${iconFileName}`);
return iconFileName;
} catch (error) {
logger.error('Failed to generate flyer icon:', { error, sourceImagePath });
// Add logging to ensure the underlying error is visible in test output or server logs
if (error instanceof Error) {
console.error('Error generating icon:', error.message, error.stack);
} else {
console.error('Error generating icon:', error);
}
logger.error({ error, sourceImagePath }, 'Failed to generate flyer icon:');
throw new Error('Icon generation failed.');
}
}

View File

@@ -1,5 +1,6 @@
// src/utils/pdfConverter.ts
import * as pdfjsLib from 'pdfjs-dist';
import { logger } from '../services/logger.client';
import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from 'pdfjs-dist';
/**
@@ -74,7 +75,7 @@ const getPdfDocument = async (pdfFile: File) => {
try {
arrayBuffer = await pdfFile.arrayBuffer();
} catch (error) {
console.warn('pdfFile.arrayBuffer() failed, falling back to FileReader', error);
logger.warn('pdfFile.arrayBuffer() failed, falling back to FileReader', { error });
arrayBuffer = await readFileAsArrayBuffer(pdfFile);
}
} else {