From aa437d61399e12e5aa9f60962273948c53398333 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Fri, 19 Dec 2025 11:20:07 -0800 Subject: [PATCH] moar fixes + unit test review of routes --- src/features/flyer/AnalysisPanel.test.tsx | 3 + .../voice-assistant/VoiceAssistant.test.tsx | 101 ++++++++++++++++-- .../voice-assistant/VoiceAssistant.tsx | 86 ++++++++------- src/pages/admin/components/AuthView.test.tsx | 5 +- .../admin/components/ProfileManager.test.tsx | 42 ++++---- src/routes/recipe.routes.ts | 18 ++-- src/routes/stats.routes.ts | 16 ++- src/routes/user.routes.ts | 7 +- 8 files changed, 195 insertions(+), 83 deletions(-) diff --git a/src/features/flyer/AnalysisPanel.test.tsx b/src/features/flyer/AnalysisPanel.test.tsx index 8ba32452..103d8348 100644 --- a/src/features/flyer/AnalysisPanel.test.tsx +++ b/src/features/flyer/AnalysisPanel.test.tsx @@ -13,6 +13,9 @@ vi.mock('../../services/logger.client', () => ({ logger: { info: vi.fn(), error: vi.fn() }, })); +// Mock the AiAnalysisService to prevent real instantiation +vi.mock('../../services/aiAnalysisService'); + // Mock the data hooks vi.mock('../../hooks/useFlyerItems'); const mockedUseFlyerItems = useFlyerItems as Mock; diff --git a/src/features/voice-assistant/VoiceAssistant.test.tsx b/src/features/voice-assistant/VoiceAssistant.test.tsx index 4f2a5e74..ffc9d33f 100644 --- a/src/features/voice-assistant/VoiceAssistant.test.tsx +++ b/src/features/voice-assistant/VoiceAssistant.test.tsx @@ -1,14 +1,18 @@ // src/features/voice-assistant/VoiceAssistant.test.tsx import React from 'react'; -import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { VoiceAssistant } from './VoiceAssistant'; import * as aiApiClient from '../../services/aiApiClient'; +import { encode } from '../../utils/audioUtils'; // Mock dependencies to isolate the component vi.mock('../../services/aiApiClient', () => ({ startVoiceSession: vi.fn(), })); +vi.mock('../../utils/audioUtils', () => ({ + encode: vi.fn(), +})); vi.mock('../../services/logger.client', () => ({ logger: { info: vi.fn(), @@ -23,6 +27,20 @@ vi.mock('../../components/icons/XMarkIcon', () => ({ XMarkIcon: () =>
, })); +// Capture the onaudioprocess callback to trigger it manually in tests. +let capturedOnaudioprocess: ((event: any) => void) | null = null; +const mockScriptProcessor = { + connect: vi.fn(), + disconnect: vi.fn(), + // Use a setter to capture the callback when the component assigns it. + set onaudioprocess(callback: ((event: any) => void) | null) { + capturedOnaudioprocess = callback; + }, + get onaudioprocess() { + return capturedOnaudioprocess; + }, +}; + // Mock browser APIs that are not available in JSDOM Object.defineProperty(window, 'AudioContext', { writable: true, @@ -31,10 +49,7 @@ Object.defineProperty(window, 'AudioContext', { createMediaStreamSource: vi.fn(() => ({ connect: vi.fn(), })), - createScriptProcessor: vi.fn(() => ({ - connect: vi.fn(), - disconnect: vi.fn(), - })), + createScriptProcessor: vi.fn(() => mockScriptProcessor), close: vi.fn(), }; }), @@ -65,6 +80,7 @@ describe('VoiceAssistant Component', () => { beforeEach(() => { vi.clearAllMocks(); capturedCallbacks = {}; // Reset before each test + capturedOnaudioprocess = null; // Reset before each test // FIX: The component's `startSession` function awaits `getUserMedia`. // We must provide a mock resolved value for the promise to prevent the test // from hanging or erroring, and to allow the async function to proceed. @@ -99,6 +115,28 @@ describe('VoiceAssistant Component', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); + it('should call handleClose on unmount to clean up resources', async () => { + // 1. Render the component, which returns an unmount function. + const { unmount } = render(); + + // 2. Start a session to ensure there are resources (session promise, media stream) to clean up. + fireEvent.click(screen.getByRole('button', { name: /start voice session/i })); + await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled()); + act(() => { + capturedCallbacks.onopen(); + }); + await screen.findByText('Listening...'); // Wait for session to be fully active. + + // 3. Unmount the component, which should trigger the useEffect cleanup function. + unmount(); + + // 4. Assert that cleanup functions were called. + expect(mockOnClose).toHaveBeenCalledTimes(1); // Called synchronously. + await waitFor(() => { + expect(mockSession.close).toHaveBeenCalledTimes(1); // Called asynchronously in a promise. + }); + }); + it('should call onClose when the overlay is clicked', () => { render(); // The overlay is the root div of the modal @@ -206,10 +244,55 @@ describe('VoiceAssistant Component', () => { expect(screen.queryByText('Model says that.')).not.toBeInTheDocument(); }); - // NOTE: Due to a stale closure bug in the component, the history will contain empty strings. - // This test correctly verifies that two new history items are added. + // The history should now contain the completed turn with the correct text. const historyContainer = screen.getByRole('heading', { name: /voice assistant/i }).parentElement?.nextElementSibling; expect(historyContainer?.children.length).toBe(2); + // Use `within` to scope queries to the history container + const { getByText } = within(historyContainer as HTMLElement); + expect(getByText('User says this.')).toBeInTheDocument(); + expect(getByText('Model says that.')).toBeInTheDocument(); + }); + + it('should send audio data via the session on onaudioprocess', async () => { + // Mock the encode function to return a predictable value + (encode as Mock).mockReturnValue('encoded-data-string'); + + render(); + fireEvent.click(screen.getByRole('button', { name: /start voice session/i })); + await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled()); + + // Trigger session open to set up audio processing + act(() => { + capturedCallbacks.onopen(); + }); + + // Wait for the onaudioprocess callback to be assigned + await waitFor(() => { + expect(capturedOnaudioprocess).toBeInstanceOf(Function); + }); + + // Create a mock audio event + const mockAudioData = new Float32Array([0.1, -0.2, 0.3]); + const mockAudioEvent = { + inputBuffer: { + getChannelData: vi.fn().mockReturnValue(mockAudioData), + }, + }; + + // Trigger the audio process event + act(() => { + capturedOnaudioprocess!(mockAudioEvent); + }); + + // Wait for the async `then` block in onaudioprocess to execute + await waitFor(() => { + expect(mockSession.sendRealtimeInput).toHaveBeenCalledTimes(1); + }); + + // Assert the payload + expect(mockSession.sendRealtimeInput).toHaveBeenCalledWith({ + media: { data: 'encoded-data-string', mimeType: 'audio/pcm;rate=16000' }, + }); }); it('should handle session error and update status', async () => { @@ -236,7 +319,9 @@ describe('VoiceAssistant Component', () => { fireEvent.click(stopButton); expect(mockOnClose).toHaveBeenCalledTimes(1); - expect(mockSession.close).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(mockSession.close).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/src/features/voice-assistant/VoiceAssistant.tsx b/src/features/voice-assistant/VoiceAssistant.tsx index 53fd3e26..4eef7800 100644 --- a/src/features/voice-assistant/VoiceAssistant.tsx +++ b/src/features/voice-assistant/VoiceAssistant.tsx @@ -33,6 +33,35 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose const audioContextRef = useRef(null); const scriptProcessorRef = useRef(null); + const startAudioStreaming = useCallback((stream: MediaStream) => { + // This function encapsulates the Web Audio API setup for streaming microphone data. + audioContextRef.current = new (window.AudioContext)({ sampleRate: 16000 }); + const source = audioContextRef.current.createMediaStreamSource(stream); + const scriptProcessor = audioContextRef.current.createScriptProcessor(4096, 1, 1); + scriptProcessorRef.current = scriptProcessor; + + scriptProcessor.onaudioprocess = (audioProcessingEvent) => { + const inputData = audioProcessingEvent.inputBuffer.getChannelData(0); + const pcmBlob: Blob = { + data: encode(new Uint8Array(new Int16Array(inputData.map(x => x * 32768)).buffer)), + mimeType: 'audio/pcm;rate=16000', + }; + // Send the encoded audio data to the voice session. + sessionPromiseRef.current?.then((session: LiveSession) => { + session.sendRealtimeInput({ media: pcmBlob }); + }); + }; + source.connect(scriptProcessor); + scriptProcessor.connect(audioContextRef.current.destination); + }, []); // No dependencies as it only uses refs and imported functions. + + const resetForNewSession = useCallback(() => { + // Centralize the state reset logic for starting a new session or closing the modal. + setHistory([]); + setUserTranscript(''); + setModelTranscript(''); + }, []); + const stopRecording = useCallback(() => { if (mediaStreamRef.current) { mediaStreamRef.current.getTracks().forEach(track => track.stop()); @@ -49,25 +78,20 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose }, []); const handleClose = useCallback(() => { - if (sessionPromiseRef.current) { - sessionPromiseRef.current.then((session: LiveSession) => session.close()); - sessionPromiseRef.current = null; - } + // Use optional chaining to simplify closing the session. + sessionPromiseRef.current?.then((session: LiveSession) => session.close()); + sessionPromiseRef.current = null; // Prevent multiple close attempts. stopRecording(); + resetForNewSession(); setStatus('idle'); - setHistory([]); - setUserTranscript(''); - setModelTranscript(''); onClose(); - }, [onClose, stopRecording]); + }, [onClose, stopRecording, resetForNewSession]); const startSession = useCallback(async () => { if (status !== 'idle' && status !== 'error') return; setStatus('connecting'); - setHistory([]); - setUserTranscript(''); - setModelTranscript(''); + resetForNewSession(); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -77,25 +101,8 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose onopen: () => { logger.debug('Voice session opened.'); setStatus('listening'); - - // Start streaming microphone audio to the model - audioContextRef.current = new (window.AudioContext)({ sampleRate: 16000 }); - const source = audioContextRef.current.createMediaStreamSource(stream); - const scriptProcessor = audioContextRef.current.createScriptProcessor(4096, 1, 1); - scriptProcessorRef.current = scriptProcessor; - - scriptProcessor.onaudioprocess = (audioProcessingEvent) => { - const inputData = audioProcessingEvent.inputBuffer.getChannelData(0); - const pcmBlob: Blob = { - data: encode(new Uint8Array(new Int16Array(inputData.map(x => x * 32768)).buffer)), - mimeType: 'audio/pcm;rate=16000', - }; - sessionPromiseRef.current?.then((session: LiveSession) => { - session.sendRealtimeInput({ media: pcmBlob }); - }); - }; - source.connect(scriptProcessor); - scriptProcessor.connect(audioContextRef.current.destination); + // The complex audio setup is now replaced by a single, clear function call. + startAudioStreaming(stream); }, onmessage: (message: LiveServerMessage) => { // NOTE: This stub doesn't play audio, just displays transcripts. @@ -113,12 +120,17 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose setModelTranscript(prev => prev + serverContent.outputTranscription!.text); } if (serverContent.turnComplete) { - setHistory(prev => [...prev, - { speaker: 'user', text: userTranscript }, - { speaker: 'model', text: modelTranscript } - ]); - setUserTranscript(''); - setModelTranscript(''); + // FIX: To prevent a stale closure, we use the functional update form for all + // related state updates. This allows us to access the latest state values + // for userTranscript and modelTranscript at the time of the update, rather + // than the stale values captured when the `onmessage` callback was created. + setUserTranscript(currentUserTranscript => { + setModelTranscript(currentModelTranscript => { + setHistory(prevHistory => [...prevHistory, { speaker: 'user', text: currentUserTranscript }, { speaker: 'model', text: currentModelTranscript }]); + return ''; // Reset model transcript + }); + return ''; // Reset user transcript + }); } }, onerror: (e: ErrorEvent) => { @@ -142,7 +154,7 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose setStatus('error'); } - }, [status, stopRecording, userTranscript, modelTranscript]); + }, [status, stopRecording, resetForNewSession, startAudioStreaming]); useEffect(() => { diff --git a/src/pages/admin/components/AuthView.test.tsx b/src/pages/admin/components/AuthView.test.tsx index 0c0c41bb..ccaa41d4 100644 --- a/src/pages/admin/components/AuthView.test.tsx +++ b/src/pages/admin/components/AuthView.test.tsx @@ -226,8 +226,9 @@ describe('AuthView', () => { const submitButton = screen.getByTestId('auth-form').querySelector('button[type="submit"]'); expect(submitButton).toBeInTheDocument(); expect(submitButton).toBeDisabled(); - // Verify 'Sign In' text is gone - expect(screen.queryByText('Sign In')).not.toBeInTheDocument(); + // Verify 'Sign In' text is gone from the button + // Note: We use queryByRole because 'Sign In' still exists in the header (h2). + expect(screen.queryByRole('button', { name: 'Sign In' })).not.toBeInTheDocument(); }); it('should show loading state during password reset submission', async () => { diff --git a/src/pages/admin/components/ProfileManager.test.tsx b/src/pages/admin/components/ProfileManager.test.tsx index e8b44381..e4b6507c 100644 --- a/src/pages/admin/components/ProfileManager.test.tsx +++ b/src/pages/admin/components/ProfileManager.test.tsx @@ -288,51 +288,49 @@ describe('ProfileManager', () => { }); it('should automatically geocode address after user stops typing', async () => { - // Only mock setTimeout/clearTimeout to prevent Date freezing which can hang waitFor - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; mockedApiClient.getUserAddress.mockResolvedValue(new Response(JSON.stringify(addressWithoutCoords))); - console.log('[TEST LOG] Rendering for automatic geocode test'); + console.log('[TEST LOG] Rendering for automatic geocode test (Real Timers)'); render(); - console.log('[TEST LOG] Waiting for initial address load...'); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown')); - console.log('[TEST LOG] Initial address loaded. Changing city...'); + console.log('[TEST LOG] Initial address loaded. Enabling Fake Timers for debounce test...'); + + // Switch to Fake Timers for Debounce logic + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); // Change address, geocode should not be called immediately fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } }); expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled(); - console.log('[TEST LOG] Advancing timers 1500ms...'); // Advance timers by 1.5 seconds await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); - console.log('[TEST LOG] Timers advanced. Checking if geocodeAddress was called'); await waitFor(() => { expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(expect.stringContaining('NewCity'), expect.anything()); expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!'); }); + vi.useRealTimers(); }); it('should not geocode if address already has coordinates', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); render(); - console.log('[TEST LOG] Waiting for initial address load (no geocode test)...'); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown')); - console.log('[TEST LOG] Initial address loaded.'); + + // Switch to Fake Timers + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - console.log('[TEST LOG] Advancing timers for "no geocode" test...'); // Advance timers await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); - console.log('[TEST LOG] Timers advanced. Checking call count'); // geocode should not have been called because the initial address had coordinates expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it('should show an error when trying to link an account', async () => { @@ -514,13 +512,13 @@ describe('ProfileManager', () => { }); it('should handle account deletion flow', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + // Use spy instead of fake timers to avoid blocking waitFor during async API calls + const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); const { unmount } = render(); - console.log('[TEST LOG] Deletion flow: clicking data privacy tab'); + fireEvent.click(screen.getByRole('button', { name: /data & privacy/i })); // Open the confirmation section - console.log('[TEST LOG] Deletion flow: clicking delete button'); fireEvent.click(screen.getByRole('button', { name: /delete my account/i })); expect(screen.getByText(/to confirm, please enter your current password/i)).toBeInTheDocument(); @@ -529,7 +527,6 @@ describe('ProfileManager', () => { fireEvent.submit(screen.getByTestId('delete-account-form')); // Confirm in the modal - console.log('[TEST LOG] Deletion flow: confirming in modal'); const confirmButton = await screen.findByRole('button', { name: /yes, delete my account/i }); fireEvent.click(confirmButton); @@ -538,17 +535,20 @@ describe('ProfileManager', () => { expect(notifySuccess).toHaveBeenCalledWith("Account deleted successfully. You will be logged out shortly."); }); - console.log('[TEST LOG] Deletion flow: Success message verified. Advancing timers 3500ms...'); - // Advance timers to trigger setTimeout - await act(async () => { - await vi.advanceTimersByTimeAsync(3500); + // Verify setTimeout was called with 3000ms + const deletionTimeoutCall = setTimeoutSpy.mock.calls.find(call => call[1] === 3000); + expect(deletionTimeoutCall).toBeDefined(); + + // Manually trigger the callback to verify cleanup + act(() => { + if (deletionTimeoutCall) (deletionTimeoutCall[0] as Function)(); }); - console.log('[TEST LOG] Timers advanced. Checking for sign out...'); expect(mockOnClose).toHaveBeenCalled(); expect(mockOnSignOut).toHaveBeenCalled(); unmount(); + setTimeoutSpy.mockRestore(); }); it('should allow toggling dark mode', async () => { diff --git a/src/routes/recipe.routes.ts b/src/routes/recipe.routes.ts index 6360b262..f01a0c4a 100644 --- a/src/routes/recipe.routes.ts +++ b/src/routes/recipe.routes.ts @@ -39,10 +39,10 @@ const recipeIdParamsSchema = z.object({ /** * GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale. */ -type BySalePercentageRequest = z.infer; router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async (req, res, next) => { try { - const { query } = req as unknown as BySalePercentageRequest; + // Explicitly parse req.query to apply coercion (string -> number) and default values + const { query } = bySalePercentageSchema.parse({ query: req.query }); const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage, req.log); res.json(recipes); } catch (error) { @@ -54,10 +54,10 @@ router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async /** * GET /api/recipes/by-sale-ingredients - Get recipes by the minimum number of sale ingredients. */ -type BySaleIngredientsRequest = z.infer; router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), async (req, res, next) => { try { - const { query } = req as unknown as BySaleIngredientsRequest; + // Explicitly parse req.query to apply coercion (string -> number) and default values + const { query } = bySaleIngredientsSchema.parse({ query: req.query }); const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(query.minIngredients, req.log); res.json(recipes); } catch (error) { @@ -69,10 +69,9 @@ router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), asy /** * GET /api/recipes/by-ingredient-and-tag - Find recipes by a specific ingredient and tag. */ -type ByIngredientAndTagRequest = z.infer; router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema), async (req, res, next) => { try { - const { query } = req as unknown as ByIngredientAndTagRequest; + const { query } = byIngredientAndTagSchema.parse({ query: req.query }); const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(query.ingredient, query.tag, req.log); res.json(recipes); } catch (error) { @@ -84,10 +83,10 @@ router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema), /** * GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe. */ -type RecipeIdRequest = z.infer; router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req, res, next) => { try { - const { params } = req as unknown as RecipeIdRequest; + // Explicitly parse req.params to coerce recipeId to a number + const { params } = recipeIdParamsSchema.parse({ params: req.params }); const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log); res.json(comments); } catch (error) { @@ -101,7 +100,8 @@ router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async ( */ router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res, next) => { try { - const { params } = req as unknown as RecipeIdRequest; + // Explicitly parse req.params to coerce recipeId to a number + const { params } = recipeIdParamsSchema.parse({ params: req.params }); const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log); res.json(recipe); } catch (error) { diff --git a/src/routes/stats.routes.ts b/src/routes/stats.routes.ts index 724d0296..f616a274 100644 --- a/src/routes/stats.routes.ts +++ b/src/routes/stats.routes.ts @@ -8,11 +8,14 @@ const router = Router(); // --- Zod Schema for Stats Routes (as per ADR-003) --- -const mostFrequentSalesSchema = z.object({ - query: z.object({ +// Define the query schema separately so we can use it to parse req.query in the handler +const statsQuerySchema = z.object({ days: z.coerce.number().int().min(1).max(365).optional().default(30), limit: z.coerce.number().int().min(1).max(50).optional().default(10), - }), +}); + +const mostFrequentSalesSchema = z.object({ + query: statsQuerySchema, }); // Infer the type from the schema for local use, as per ADR-003. @@ -24,8 +27,11 @@ type MostFrequentSalesRequest = z.infer; */ router.get('/most-frequent-sales', validateRequest(mostFrequentSalesSchema), async (req: Request, res: Response, next: NextFunction) => { try { - const { query } = req as unknown as MostFrequentSalesRequest; - const items = await db.adminRepo.getMostFrequentSaleItems(query.days, query.limit, req.log); + // Parse req.query to ensure coercion (string -> number) and defaults are applied. + // Even though validateRequest checks validity, it may not mutate req.query with the parsed result. + const { days, limit } = statsQuerySchema.parse(req.query); + + const items = await db.adminRepo.getMostFrequentSaleItems(days, limit, req.log); res.json(items); } catch (error) { req.log.error({ error }, 'Error fetching most frequent sale items in /api/stats/most-frequent-sales:'); diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index 873647ab..9db6a4ac 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -146,7 +146,10 @@ router.get( // Apply ADR-003 pattern for type safety try { const { query } = req as unknown as GetNotificationsRequest; - const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log); + // Explicitly convert to numbers to ensure the repo receives correct types + const limit = query.limit ? Number(query.limit) : 20; + const offset = query.offset ? Number(query.offset) : 0; + const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset, req.log); res.json(notifications); } catch (error) { next(error); @@ -645,6 +648,7 @@ router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, await db.recipeRepo.deleteRecipe(params.recipeId, user.user_id, false, req.log); res.status(204).send(); } catch (error) { + logger.error({ error, params: req.params }, `[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`); next(error); } }); @@ -674,6 +678,7 @@ router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req const updatedRecipe = await db.recipeRepo.updateRecipe(params.recipeId, user.user_id, body, req.log); res.json(updatedRecipe); } catch (error) { + logger.error({ error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/recipes/:recipeId - ERROR`); next(error); } });