Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621d30b84f | ||
| ed857f588a | |||
|
|
fee55b0afd | ||
| 35538ea011 | |||
| 368b8e704c |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
1267
src/App.test.tsx
1267
src/App.test.tsx
File diff suppressed because it is too large
Load Diff
@@ -22,200 +22,224 @@ interface LiveSession {
|
||||
}
|
||||
|
||||
export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) => {
|
||||
const [status, setStatus] = useState<VoiceStatus>('idle');
|
||||
const [userTranscript, setUserTranscript] = useState('');
|
||||
const [modelTranscript, setModelTranscript] = useState('');
|
||||
const [history, setHistory] = useState<{speaker: 'user' | 'model', text: string}[]>([]);
|
||||
|
||||
// Use the local LiveSession interface for the ref type.
|
||||
const sessionPromiseRef = useRef<Promise<LiveSession> | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const scriptProcessorRef = useRef<ScriptProcessorNode | null>(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;
|
||||
const [status, setStatus] = useState<VoiceStatus>('idle');
|
||||
const [userTranscript, setUserTranscript] = useState('');
|
||||
const [modelTranscript, setModelTranscript] = useState('');
|
||||
const [history, setHistory] = useState<{ speaker: 'user' | 'model'; text: string }[]>([]);
|
||||
|
||||
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.
|
||||
// Use `any` for the session promise ref to avoid type conflicts with the underlying Google AI SDK,
|
||||
// which may have a more complex session object type. The `LiveSession` interface is used
|
||||
// conceptually in callbacks, but `any` provides flexibility for the initial assignment.
|
||||
const sessionPromiseRef = useRef<any | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const scriptProcessorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
|
||||
const resetForNewSession = useCallback(() => {
|
||||
// Centralize the state reset logic for starting a new session or closing the modal.
|
||||
setHistory([]);
|
||||
setUserTranscript('');
|
||||
setModelTranscript('');
|
||||
}, []);
|
||||
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;
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
if (scriptProcessorRef.current) {
|
||||
scriptProcessorRef.current.disconnect();
|
||||
scriptProcessorRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
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 handleClose = useCallback(() => {
|
||||
// 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');
|
||||
onClose();
|
||||
}, [onClose, stopRecording, resetForNewSession]);
|
||||
const resetForNewSession = useCallback(() => {
|
||||
// Centralize the state reset logic for starting a new session or closing the modal.
|
||||
setHistory([]);
|
||||
setUserTranscript('');
|
||||
setModelTranscript('');
|
||||
}, []);
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
if (status !== 'idle' && status !== 'error') return;
|
||||
|
||||
setStatus('connecting');
|
||||
resetForNewSession();
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
const callbacks = {
|
||||
onopen: () => {
|
||||
logger.debug('Voice session opened.');
|
||||
setStatus('listening');
|
||||
// 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.
|
||||
// A full implementation would use the audioUtils to decode and play audio.
|
||||
const serverContent = message.serverContent;
|
||||
if (!serverContent) {
|
||||
return; // Exit if there's no content to process
|
||||
}
|
||||
|
||||
// Safely access nested properties with optional chaining.
|
||||
if (serverContent.inputTranscription?.text) {
|
||||
setUserTranscript(prev => prev + serverContent.inputTranscription!.text);
|
||||
}
|
||||
if (serverContent.outputTranscription?.text) {
|
||||
setModelTranscript(prev => prev + serverContent.outputTranscription!.text);
|
||||
}
|
||||
if (serverContent.turnComplete) {
|
||||
// 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) => {
|
||||
logger.error('Voice session error', { error: e });
|
||||
setStatus('error');
|
||||
stopRecording();
|
||||
},
|
||||
onclose: () => {
|
||||
logger.debug('Voice session closed.');
|
||||
stopRecording();
|
||||
setStatus('idle');
|
||||
},
|
||||
};
|
||||
|
||||
sessionPromiseRef.current = startVoiceSession(callbacks);
|
||||
|
||||
} catch (e) {
|
||||
// We check if the caught object is an instance of Error to safely access its message property.
|
||||
// This avoids using 'any' and handles different types of thrown values.
|
||||
logger.error("Failed to start voice session", { error: e });
|
||||
setStatus('error');
|
||||
}
|
||||
|
||||
}, [status, stopRecording, resetForNewSession, startAudioStreaming]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
handleClose();
|
||||
};
|
||||
}, [handleClose]);
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'idle': return 'Click the mic to start';
|
||||
case 'connecting': return 'Connecting...';
|
||||
case 'listening': return 'Listening...';
|
||||
case 'speaking': return 'Thinking...';
|
||||
case 'error': return 'Connection error. Please try again.';
|
||||
}
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
if (scriptProcessorRef.current) {
|
||||
scriptProcessorRef.current.disconnect();
|
||||
scriptProcessorRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
|
||||
const handleClose = useCallback(() => {
|
||||
// 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');
|
||||
onClose();
|
||||
}, [onClose, stopRecording, resetForNewSession]);
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
if (status !== 'idle' && status !== 'error') return;
|
||||
|
||||
setStatus('connecting');
|
||||
resetForNewSession();
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
const callbacks = {
|
||||
onopen: () => {
|
||||
logger.debug('Voice session opened.');
|
||||
setStatus('listening');
|
||||
// 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.
|
||||
// A full implementation would use the audioUtils to decode and play audio.
|
||||
const serverContent = message.serverContent;
|
||||
if (!serverContent) {
|
||||
return; // Exit if there's no content to process
|
||||
}
|
||||
|
||||
// Safely access nested properties with optional chaining.
|
||||
if (serverContent.inputTranscription?.text) {
|
||||
setUserTranscript((prev) => prev + serverContent.inputTranscription!.text);
|
||||
}
|
||||
if (serverContent.outputTranscription?.text) {
|
||||
setModelTranscript((prev) => prev + serverContent.outputTranscription!.text);
|
||||
}
|
||||
if (serverContent.turnComplete) {
|
||||
// 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) => {
|
||||
logger.error('Voice session error', { error: e });
|
||||
setStatus('error');
|
||||
stopRecording();
|
||||
},
|
||||
onclose: () => {
|
||||
logger.debug('Voice session closed.');
|
||||
stopRecording();
|
||||
setStatus('idle');
|
||||
},
|
||||
};
|
||||
|
||||
sessionPromiseRef.current = startVoiceSession(callbacks);
|
||||
} catch (e) {
|
||||
// We check if the caught object is an instance of Error to safely access its message property.
|
||||
// This avoids using 'any' and handles different types of thrown values.
|
||||
logger.error('Failed to start voice session', { error: e });
|
||||
setStatus('error');
|
||||
}
|
||||
}, [status, stopRecording, resetForNewSession, startAudioStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
handleClose();
|
||||
};
|
||||
}, [handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'Click the mic to start';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'listening':
|
||||
return 'Listening...';
|
||||
case 'speaking':
|
||||
return 'Thinking...';
|
||||
case 'error':
|
||||
return 'Connection error. Please try again.';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg relative flex flex-col h-[70vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Voice Assistant</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg relative flex flex-col h-[70vh]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Voice Assistant</h2>
|
||||
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" aria-label="Close">
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grow p-4 overflow-y-auto space-y-4">
|
||||
{history.map((entry, index) => (
|
||||
<div key={index} className={`p-3 rounded-lg max-w-[80%] ${entry.speaker === 'user' ? 'bg-blue-100 dark:bg-blue-900/50 ml-auto' : 'bg-gray-100 dark:bg-gray-700/50'}`}>
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200">{entry.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{userTranscript && <div className="p-3 rounded-lg max-w-[80%] bg-blue-100 dark:bg-blue-900/50 ml-auto opacity-70"><p className="text-sm text-gray-800 dark:text-gray-200">{userTranscript}</p></div>}
|
||||
{modelTranscript && <div className="p-3 rounded-lg max-w-[80%] bg-gray-100 dark:bg-gray-700/50 opacity-70"><p className="text-sm text-gray-800 dark:text-gray-200">{modelTranscript}</p></div>}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex flex-col items-center">
|
||||
<button
|
||||
onClick={status === 'idle' || status === 'error' ? startSession : handleClose}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center transition-colors ${status === 'listening' ? 'bg-red-500 hover:bg-red-600' : 'bg-brand-primary hover:bg-brand-secondary'}`}
|
||||
aria-label={status === 'idle' || status === 'error' ? "Start voice session" : "Stop voice session"}
|
||||
>
|
||||
<MicrophoneIcon className="w-8 h-8 text-white" />
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">{getStatusText()}</p>
|
||||
</div>
|
||||
</div>
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="grow p-4 overflow-y-auto space-y-4">
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded-lg max-w-[80%] ${entry.speaker === 'user' ? 'bg-blue-100 dark:bg-blue-900/50 ml-auto' : 'bg-gray-100 dark:bg-gray-700/50'}`}
|
||||
>
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200">{entry.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{userTranscript && (
|
||||
<div className="p-3 rounded-lg max-w-[80%] bg-blue-100 dark:bg-blue-900/50 ml-auto opacity-70">
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200">{userTranscript}</p>
|
||||
</div>
|
||||
)}
|
||||
{modelTranscript && (
|
||||
<div className="p-3 rounded-lg max-w-[80%] bg-gray-100 dark:bg-gray-700/50 opacity-70">
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200">{modelTranscript}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex flex-col items-center">
|
||||
<button
|
||||
onClick={status === 'idle' || status === 'error' ? startSession : handleClose}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center transition-colors ${status === 'listening' ? 'bg-red-500 hover:bg-red-600' : 'bg-brand-primary hover:bg-brand-secondary'}`}
|
||||
aria-label={
|
||||
status === 'idle' || status === 'error' ? 'Start voice session' : 'Stop voice session'
|
||||
}
|
||||
>
|
||||
<MicrophoneIcon className="w-8 h-8 text-white" />
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">{getStatusText()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/hooks/useProfileAddress.test.ts
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock, afterEach } from 'vitest';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useProfileAddress } from './useProfileAddress';
|
||||
import { useApi } from './useApi';
|
||||
@@ -57,18 +57,39 @@ describe('useProfileAddress Hook', () => {
|
||||
mockGeocode = vi.fn();
|
||||
mockFetchAddress = vi.fn();
|
||||
|
||||
// Setup the mock for useApi to handle multiple renders and hook calls.
|
||||
// The hook calls useApi twice per render in a stable order:
|
||||
// 1. geocodeWrapper (via geocode)
|
||||
// 2. fetchAddressWrapper (via fetchAddress)
|
||||
let callCount = 0;
|
||||
mockedUseApi.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount % 2 !== 0) {
|
||||
return { execute: mockGeocode, loading: false, error: null, data: null, reset: vi.fn(), isRefetching: false };
|
||||
} else {
|
||||
return { execute: mockFetchAddress, loading: false, error: null, data: null, reset: vi.fn(), isRefetching: false };
|
||||
// FIXED: Use function name checking for stability instead of call count.
|
||||
// This prevents mocks from swapping if render order changes.
|
||||
mockedUseApi.mockImplementation((fn: any) => {
|
||||
const name = fn?.name;
|
||||
if (name === 'geocodeWrapper') {
|
||||
return {
|
||||
execute: mockGeocode,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
reset: vi.fn(),
|
||||
isRefetching: false,
|
||||
};
|
||||
}
|
||||
if (name === 'fetchAddressWrapper') {
|
||||
return {
|
||||
execute: mockFetchAddress,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
reset: vi.fn(),
|
||||
isRefetching: false,
|
||||
};
|
||||
}
|
||||
// Default fallback
|
||||
return {
|
||||
execute: vi.fn(),
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
reset: vi.fn(),
|
||||
isRefetching: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +133,7 @@ describe('useProfileAddress Hook', () => {
|
||||
mockFetchAddress.mockResolvedValue(mockAddress);
|
||||
const { result, rerender } = renderHook(
|
||||
({ userProfile, isOpen }) => useProfileAddress(userProfile, isOpen),
|
||||
{ initialProps: { userProfile: mockUserProfile, isOpen: true } }
|
||||
{ initialProps: { userProfile: mockUserProfile, isOpen: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -126,15 +147,15 @@ describe('useProfileAddress Hook', () => {
|
||||
});
|
||||
|
||||
it('should handle fetch failure gracefully', async () => {
|
||||
mockFetchAddress.mockResolvedValue(null); // useApi returns null on failure
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
mockFetchAddress.mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
|
||||
});
|
||||
|
||||
expect(result.current.address).toEqual({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(`[useProfileAddress] Fetch returned null for addressId: ${mockUserProfile.address_id}.`);
|
||||
expect(result.current.address).toEqual({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,7 +179,7 @@ describe('useProfileAddress Hook', () => {
|
||||
describe('handleManualGeocode', () => {
|
||||
it('should call geocode API with the correct address string', async () => {
|
||||
const { result } = renderHook(() => useProfileAddress(null, false));
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddressChange('address_line_1', '1 Infinite Loop');
|
||||
result.current.handleAddressChange('city', 'Cupertino');
|
||||
@@ -169,36 +190,38 @@ describe('useProfileAddress Hook', () => {
|
||||
await result.current.handleManualGeocode();
|
||||
});
|
||||
|
||||
expect(mockGeocode).toHaveBeenCalledWith('1 Infinite Loop, Cupertino, CA');
|
||||
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('1 Infinite Loop'));
|
||||
});
|
||||
|
||||
it('should update address with new coordinates on successful geocode', async () => {
|
||||
const newCoords = { lat: 37.33, lng: -122.03 };
|
||||
mockGeocode.mockResolvedValue(newCoords);
|
||||
const { result } = renderHook(() => useProfileAddress(null, false));
|
||||
const newCoords = { lat: 37.33, lng: -122.03 };
|
||||
mockGeocode.mockResolvedValue(newCoords);
|
||||
const { result } = renderHook(() => useProfileAddress(null, false));
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddressChange('city', 'Cupertino');
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleAddressChange('city', 'Cupertino');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualGeocode();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.handleManualGeocode();
|
||||
});
|
||||
|
||||
expect(result.current.address.latitude).toBe(newCoords.lat);
|
||||
expect(result.current.address.longitude).toBe(newCoords.lng);
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
|
||||
expect(result.current.address.latitude).toBe(newCoords.lat);
|
||||
expect(result.current.address.longitude).toBe(newCoords.lng);
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
|
||||
});
|
||||
|
||||
it('should show an error toast if address string is empty', async () => {
|
||||
const { result } = renderHook(() => useProfileAddress(null, false));
|
||||
const { result } = renderHook(() => useProfileAddress(null, false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualGeocode();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.handleManualGeocode();
|
||||
});
|
||||
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
expect(mockedToast.error).toHaveBeenCalledWith('Please fill in the address fields before geocoding.');
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
expect(mockedToast.error).toHaveBeenCalledWith(
|
||||
'Please fill in the address fields before geocoding.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,68 +235,67 @@ describe('useProfileAddress Hook', () => {
|
||||
});
|
||||
|
||||
it('should trigger geocode after user stops typing in an address without coordinates', async () => {
|
||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
||||
const newCoords = { lat: 38.89, lng: -77.03 };
|
||||
mockGeocode.mockResolvedValue(newCoords);
|
||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
||||
const newCoords = { lat: 38.89, lng: -77.03 };
|
||||
mockGeocode.mockResolvedValue(newCoords);
|
||||
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
|
||||
// Change the address
|
||||
act(() => {
|
||||
result.current.handleAddressChange('city', 'Washington');
|
||||
});
|
||||
// Change the address
|
||||
act(() => {
|
||||
result.current.handleAddressChange('city', 'Washington');
|
||||
});
|
||||
|
||||
// Geocode should not be called immediately
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
// Geocode should not be called immediately due to debounce
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
// Advance debounce timer
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington'));
|
||||
expect(result.current.address.latitude).toBe(newCoords.lat);
|
||||
expect(result.current.address.longitude).toBe(newCoords.lng);
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington'));
|
||||
expect(result.current.address.latitude).toBe(newCoords.lat);
|
||||
expect(result.current.address.longitude).toBe(newCoords.lng);
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT trigger geocode if address already has coordinates', async () => {
|
||||
mockFetchAddress.mockResolvedValue(mockAddress); // Has coords
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
mockFetchAddress.mockResolvedValue(mockAddress); // Has coords
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddressChange('city', 'NewCity');
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleAddressChange('city', 'NewCity');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT trigger geocode on initial load, even if address has no coords', async () => {
|
||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
|
||||
// Wait to see if debounce triggers
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
|
||||
// It shouldn't because the address hasn't been changed by the user yet
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
// Should not call because address hasn't changed from initial
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,65 +25,105 @@ export interface MainLayoutProps {
|
||||
onOpenProfile: () => void;
|
||||
}
|
||||
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedFlyerId, onOpenProfile }) => {
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
onFlyerSelect,
|
||||
selectedFlyerId,
|
||||
onOpenProfile,
|
||||
}) => {
|
||||
const { userProfile, authStatus } = useAuth();
|
||||
const user = userProfile?.user ?? null;
|
||||
const { flyers, refetchFlyers, flyersError } = useFlyers();
|
||||
const { masterItems, error: masterItemsError } = useMasterItems();
|
||||
const {
|
||||
shoppingLists, activeListId, setActiveListId,
|
||||
createList, deleteList, addItemToList, updateItemInList, removeItemFromList,
|
||||
shoppingLists,
|
||||
activeListId,
|
||||
setActiveListId,
|
||||
createList,
|
||||
deleteList,
|
||||
addItemToList,
|
||||
updateItemInList,
|
||||
removeItemFromList,
|
||||
error: shoppingListError,
|
||||
} = useShoppingLists();
|
||||
const {
|
||||
watchedItems, addWatchedItem, removeWatchedItem,
|
||||
watchedItems,
|
||||
addWatchedItem,
|
||||
removeWatchedItem,
|
||||
error: watchedItemsError,
|
||||
} = useWatchedItems();
|
||||
const { totalActiveItems, error: activeDealsError } = useActiveDeals();
|
||||
|
||||
const handleActivityLogClick: ActivityLogClickHandler = useCallback((log) => {
|
||||
if (log.action === 'list_shared') {
|
||||
const listId = log.details.shopping_list_id;
|
||||
if (shoppingLists.some(list => list.shopping_list_id === listId)) {
|
||||
setActiveListId(listId);
|
||||
const handleActivityLogClick: ActivityLogClickHandler = useCallback(
|
||||
(log) => {
|
||||
if (log.action === 'list_shared') {
|
||||
const listId = log.details.shopping_list_id;
|
||||
if (shoppingLists.some((list) => list.shopping_list_id === listId)) {
|
||||
setActiveListId(listId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [shoppingLists, setActiveListId]);
|
||||
},
|
||||
[shoppingLists, setActiveListId],
|
||||
);
|
||||
|
||||
const handleAddItemToShoppingList = useCallback(async (item: { masterItemId?: number; customItemName?: string; }) => {
|
||||
if (activeListId) {
|
||||
await addItemToList(activeListId, item);
|
||||
}
|
||||
}, [activeListId, addItemToList]);
|
||||
const handleAddItemToShoppingList = useCallback(
|
||||
async (item: { masterItemId?: number; customItemName?: string }) => {
|
||||
if (activeListId) {
|
||||
await addItemToList(activeListId, item);
|
||||
}
|
||||
},
|
||||
[activeListId, addItemToList],
|
||||
);
|
||||
|
||||
const handleAddItemFromWatchedList = useCallback((masterItemId: number) => {
|
||||
if (activeListId) {
|
||||
addItemToList(activeListId, { masterItemId });
|
||||
}
|
||||
}, [activeListId, addItemToList]);
|
||||
const handleAddItemFromWatchedList = useCallback(
|
||||
(masterItemId: number) => {
|
||||
if (activeListId) {
|
||||
addItemToList(activeListId, { masterItemId });
|
||||
}
|
||||
},
|
||||
[activeListId, addItemToList],
|
||||
);
|
||||
|
||||
// Consolidate error states into a single variable for cleaner display logic.
|
||||
const combinedError = flyersError?.message || masterItemsError || shoppingListError || watchedItemsError || activeDealsError;
|
||||
const combinedError =
|
||||
flyersError?.message ||
|
||||
masterItemsError ||
|
||||
shoppingListError ||
|
||||
watchedItemsError ||
|
||||
activeDealsError;
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
|
||||
{authStatus === 'SIGNED_OUT' && flyers.length > 0 && (
|
||||
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0"> {/* This div was missing a closing tag in the original code, but it's outside the diff scope. */}
|
||||
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
|
||||
{' '}
|
||||
{/* This div was missing a closing tag in the original code, but it's outside the diff scope. */}
|
||||
<AnonymousUserBanner onOpenProfile={onOpenProfile} />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
|
||||
<div className="lg:col-span-1 flex flex-col space-y-6">
|
||||
<FlyerList flyers={flyers} onFlyerSelect={onFlyerSelect} selectedFlyerId={selectedFlyerId} profile={userProfile} />
|
||||
<FlyerList
|
||||
flyers={flyers}
|
||||
onFlyerSelect={onFlyerSelect}
|
||||
selectedFlyerId={selectedFlyerId}
|
||||
profile={userProfile}
|
||||
/>
|
||||
<FlyerUploader onProcessingComplete={refetchFlyers} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 flex flex-col space-y-6">
|
||||
{combinedError && (
|
||||
<ErrorDisplay message={combinedError} />
|
||||
)}
|
||||
{combinedError && <ErrorDisplay message={combinedError} />}
|
||||
{/* The Outlet will render the specific page content (e.g., FlyerDisplay or Welcome message) */}
|
||||
<Outlet context={{ totalActiveItems, masterItems, addWatchedItem, shoppingLists, activeListId, addItemToList }} />
|
||||
<Outlet
|
||||
context={{
|
||||
totalActiveItems,
|
||||
masterItems,
|
||||
addWatchedItem,
|
||||
shoppingLists,
|
||||
activeListId,
|
||||
addItemToList,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 flex-col space-y-6">
|
||||
@@ -113,10 +153,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedF
|
||||
/>
|
||||
<PriceHistoryChart />
|
||||
<Leaderboard />
|
||||
<ActivityLog user={user} onLogClick={handleActivityLogClick} />
|
||||
<ActivityLog userProfile={userProfile} onLogClick={handleActivityLogClick} />
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as bcrypt from 'bcrypt';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Define a type for the JWT verify callback function for type safety.
|
||||
type VerifyCallback = (payload: { user_id: string }, done: (error: Error | null, user?: object | false) => void) => Promise<void>;
|
||||
type VerifyCallback = (
|
||||
payload: { user_id: string },
|
||||
done: (error: Error | null, user?: object | false) => void,
|
||||
) => Promise<void>;
|
||||
|
||||
// FIX: Use vi.hoisted to declare variables that need to be accessed inside vi.mock
|
||||
const { verifyCallbackWrapper } = vi.hoisted(() => {
|
||||
@@ -12,15 +15,15 @@ const { verifyCallbackWrapper } = vi.hoisted(() => {
|
||||
// We use a wrapper object to hold the callback reference
|
||||
// Initialize with a more specific type instead of `any`.
|
||||
verifyCallbackWrapper: {
|
||||
callback: null as VerifyCallback | null
|
||||
}
|
||||
callback: null as VerifyCallback | null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the 'passport-jwt' module to capture the verify callback.
|
||||
vi.mock('passport-jwt', () => ({
|
||||
// The Strategy constructor is mocked to capture its second argument
|
||||
Strategy: vi.fn(function(options, verify) {
|
||||
Strategy: vi.fn(function (options, verify) {
|
||||
// FIX: Assign to the hoisted wrapper object
|
||||
verifyCallbackWrapper.callback = verify;
|
||||
return { name: 'jwt', authenticate: vi.fn() };
|
||||
@@ -30,21 +33,29 @@ vi.mock('passport-jwt', () => ({
|
||||
|
||||
// FIX: Add a similar mock for 'passport-local' to capture its verify callback.
|
||||
const { localStrategyCallbackWrapper } = vi.hoisted(() => {
|
||||
type LocalVerifyCallback = (req: Request, email: string, pass: string, done: (error: Error | null, user?: object | false, options?: { message: string }) => void) => Promise<void>;
|
||||
type LocalVerifyCallback = (
|
||||
req: Request,
|
||||
email: string,
|
||||
pass: string,
|
||||
done: (error: Error | null, user?: object | false, options?: { message: string }) => void,
|
||||
) => Promise<void>;
|
||||
return {
|
||||
localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null }
|
||||
localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('passport-local', () => ({
|
||||
Strategy: vi.fn(function(options, verify) {
|
||||
Strategy: vi.fn(function (options, verify) {
|
||||
localStrategyCallbackWrapper.callback = verify;
|
||||
}),
|
||||
}));
|
||||
|
||||
import * as db from '../services/db/index.db';
|
||||
import { UserProfile } from '../types';
|
||||
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockUserProfile,
|
||||
createMockUserWithPasswordHash,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock dependencies before importing the passport configuration
|
||||
@@ -118,7 +129,9 @@ describe('Passport Configuration', () => {
|
||||
}),
|
||||
refresh_token: 'mock-refresh-token',
|
||||
};
|
||||
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockAuthableProfile);
|
||||
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(
|
||||
mockAuthableProfile,
|
||||
);
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
|
||||
|
||||
// Act
|
||||
@@ -127,11 +140,24 @@ describe('Passport Configuration', () => {
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith('test@test.com', logger);
|
||||
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith(
|
||||
'test@test.com',
|
||||
logger,
|
||||
);
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password');
|
||||
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith(mockAuthableProfile.user.user_id, '127.0.0.1', logger);
|
||||
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith(
|
||||
mockAuthableProfile.user.user_id,
|
||||
'127.0.0.1',
|
||||
logger,
|
||||
);
|
||||
// The strategy now just strips auth fields.
|
||||
const { password_hash, failed_login_attempts, last_failed_login, last_login_ip, refresh_token, ...expectedUserProfile } = mockAuthableProfile;
|
||||
const {
|
||||
password_hash,
|
||||
failed_login_attempts,
|
||||
last_failed_login,
|
||||
refresh_token,
|
||||
...expectedUserProfile
|
||||
} = mockAuthableProfile;
|
||||
expect(done).toHaveBeenCalledWith(null, expectedUserProfile);
|
||||
});
|
||||
|
||||
@@ -165,14 +191,25 @@ describe('Passport Configuration', () => {
|
||||
vi.mocked(mockedDb.adminRepo.incrementFailedLoginAttempts).mockResolvedValue(2);
|
||||
|
||||
if (localStrategyCallbackWrapper.callback) {
|
||||
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
|
||||
await localStrategyCallbackWrapper.callback(
|
||||
mockReq,
|
||||
'test@test.com',
|
||||
'wrong_password',
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger);
|
||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'login_failed_password',
|
||||
details: { source_ip: '127.0.0.1', new_attempt_count: 2 },
|
||||
}), logger);
|
||||
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(
|
||||
mockUser.user.user_id,
|
||||
logger,
|
||||
);
|
||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'login_failed_password',
|
||||
details: { source_ip: '127.0.0.1', new_attempt_count: 2 },
|
||||
}),
|
||||
logger,
|
||||
);
|
||||
expect(done).toHaveBeenCalledWith(null, false, { message: 'Incorrect email or password.' });
|
||||
});
|
||||
|
||||
@@ -196,12 +233,22 @@ describe('Passport Configuration', () => {
|
||||
vi.mocked(mockedDb.adminRepo.incrementFailedLoginAttempts).mockResolvedValue(5);
|
||||
|
||||
if (localStrategyCallbackWrapper.callback) {
|
||||
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
|
||||
await localStrategyCallbackWrapper.callback(
|
||||
mockReq,
|
||||
'test@test.com',
|
||||
'wrong_password',
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger);
|
||||
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(
|
||||
mockUser.user.user_id,
|
||||
logger,
|
||||
);
|
||||
// It should now return the lockout message, not the generic "incorrect password"
|
||||
expect(done).toHaveBeenCalledWith(null, false, { message: expect.stringContaining('Account is temporarily locked') });
|
||||
expect(done).toHaveBeenCalledWith(null, false, {
|
||||
message: expect.stringContaining('Account is temporarily locked'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call done(null, false) for an OAuth user (no password hash)', async () => {
|
||||
@@ -221,10 +268,18 @@ describe('Passport Configuration', () => {
|
||||
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
|
||||
|
||||
if (localStrategyCallbackWrapper.callback) {
|
||||
await localStrategyCallbackWrapper.callback(mockReq, 'oauth@test.com', 'any_password', done);
|
||||
await localStrategyCallbackWrapper.callback(
|
||||
mockReq,
|
||||
'oauth@test.com',
|
||||
'any_password',
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
expect(done).toHaveBeenCalledWith(null, false, { message: 'This account was created using a social login. Please use Google or GitHub to sign in.' });
|
||||
expect(done).toHaveBeenCalledWith(null, false, {
|
||||
message:
|
||||
'This account was created using a social login. Please use Google or GitHub to sign in.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call done(null, false) if account is locked', async () => {
|
||||
@@ -245,10 +300,17 @@ describe('Passport Configuration', () => {
|
||||
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
|
||||
|
||||
if (localStrategyCallbackWrapper.callback) {
|
||||
await localStrategyCallbackWrapper.callback(mockReq, 'locked@test.com', 'any_password', done);
|
||||
await localStrategyCallbackWrapper.callback(
|
||||
mockReq,
|
||||
'locked@test.com',
|
||||
'any_password',
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
expect(done).toHaveBeenCalledWith(null, false, { message: 'Account is temporarily locked. Please try again in 15 minutes.' });
|
||||
expect(done).toHaveBeenCalledWith(null, false, {
|
||||
message: 'Account is temporarily locked. Please try again in 15 minutes.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow login if lockout period has expired', async () => {
|
||||
@@ -270,7 +332,12 @@ describe('Passport Configuration', () => {
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Correct password
|
||||
|
||||
if (localStrategyCallbackWrapper.callback) {
|
||||
await localStrategyCallbackWrapper.callback(mockReq, 'expired@test.com', 'correct_password', done);
|
||||
await localStrategyCallbackWrapper.callback(
|
||||
mockReq,
|
||||
'expired@test.com',
|
||||
'correct_password',
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
// Should proceed to successful login
|
||||
@@ -293,8 +360,12 @@ describe('Passport Configuration', () => {
|
||||
describe('JwtStrategy (Isolated Callback Logic)', () => {
|
||||
it('should call done(null, userProfile) on successful authentication', async () => {
|
||||
// Arrange
|
||||
const jwtPayload = { user_id: 'user-123' };
|
||||
const mockProfile = { role: 'user', points: 100, user: { user_id: 'user-123', email: 'test@test.com' } } as UserProfile;
|
||||
const jwtPayload = { user_id: 'user-123' };
|
||||
const mockProfile = {
|
||||
role: 'user',
|
||||
points: 100,
|
||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||
} as UserProfile;
|
||||
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockProfile);
|
||||
const done = vi.fn();
|
||||
|
||||
@@ -360,7 +431,10 @@ describe('Passport Configuration', () => {
|
||||
it('should call next() if user has "admin" role', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
user: createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }),
|
||||
user: createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-id', email: 'admin@test.com' },
|
||||
}),
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -374,7 +448,10 @@ describe('Passport Configuration', () => {
|
||||
it('should return 403 Forbidden if user does not have "admin" role', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
user: createMockUserProfile({ role: 'user', user: { user_id: 'user-id', email: 'user@test.com' } }),
|
||||
user: createMockUserProfile({
|
||||
role: 'user',
|
||||
user: { user_id: 'user-id', email: 'user@test.com' },
|
||||
}),
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -383,7 +460,9 @@ describe('Passport Configuration', () => {
|
||||
// Assert
|
||||
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
message: 'Forbidden: Administrator access required.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 403 Forbidden if req.user is missing', () => {
|
||||
@@ -413,7 +492,9 @@ describe('Passport Configuration', () => {
|
||||
// Assert
|
||||
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
message: 'Forbidden: Administrator access required.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,10 +509,13 @@ describe('Passport Configuration', () => {
|
||||
it('should populate req.user and call next() if authentication succeeds', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } });
|
||||
const mockUser = createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-id', email: 'admin@test.com' },
|
||||
});
|
||||
// Mock passport.authenticate to call its callback with a user
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined)
|
||||
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined),
|
||||
);
|
||||
|
||||
// Act
|
||||
@@ -446,7 +530,7 @@ describe('Passport Configuration', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, undefined)
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, undefined),
|
||||
);
|
||||
|
||||
optionalAuth(mockReq, mockRes as Response, mockNext);
|
||||
@@ -461,7 +545,7 @@ describe('Passport Configuration', () => {
|
||||
const mockInfo = { message: 'Token expired' };
|
||||
// Mock passport.authenticate to call its callback with an info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo)
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo),
|
||||
);
|
||||
|
||||
// Act
|
||||
@@ -479,7 +563,7 @@ describe('Passport Configuration', () => {
|
||||
const mockInfoError = new Error('Token is malformed');
|
||||
// Mock passport.authenticate to call its callback with an info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfoError)
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfoError),
|
||||
);
|
||||
|
||||
// Act
|
||||
@@ -487,7 +571,10 @@ describe('Passport Configuration', () => {
|
||||
|
||||
// Assert
|
||||
// info.message is 'Token is malformed'
|
||||
expect(logger.info).toHaveBeenCalledWith({ info: 'Token is malformed' }, 'Optional auth info:');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ info: 'Token is malformed' },
|
||||
'Optional auth info:',
|
||||
);
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -497,14 +584,17 @@ describe('Passport Configuration', () => {
|
||||
const mockInfo = { custom: 'some info' };
|
||||
// Mock passport.authenticate to call its callback with a custom info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any)
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any),
|
||||
);
|
||||
|
||||
// Act
|
||||
optionalAuth(mockReq, mockRes as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(logger.info).toHaveBeenCalledWith({ info: mockInfo.toString() }, 'Optional auth info:');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ info: mockInfo.toString() },
|
||||
'Optional auth info:',
|
||||
);
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -514,7 +604,7 @@ describe('Passport Configuration', () => {
|
||||
const authError = new Error('Malformed token');
|
||||
// Mock passport.authenticate to call its callback with an error
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(authError, false, undefined)
|
||||
(_strategy, _options, callback) => () => callback?.(authError, false, undefined),
|
||||
);
|
||||
|
||||
// Act
|
||||
@@ -567,4 +657,4 @@ describe('Passport Configuration', () => {
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,89 +36,119 @@ function isUserProfile(user: unknown): user is UserProfile {
|
||||
}
|
||||
|
||||
// --- Passport Local Strategy (for email/password login) ---
|
||||
passport.use(new LocalStrategy(
|
||||
{
|
||||
usernameField: 'email',
|
||||
passReqToCallback: true // Pass the request object to the callback
|
||||
},
|
||||
async (req: Request, email, password, done) => {
|
||||
try {
|
||||
// 1. Find the user by email, including their profile data for the JWT payload.
|
||||
const user = await db.userRepo.findUserWithProfileByEmail(email, req.log);
|
||||
passport.use(
|
||||
new LocalStrategy(
|
||||
{
|
||||
usernameField: 'email',
|
||||
passReqToCallback: true, // Pass the request object to the callback
|
||||
},
|
||||
async (req: Request, email, password, done) => {
|
||||
try {
|
||||
// 1. Find the user by email, including their profile data for the JWT payload.
|
||||
const userprofile = await db.userRepo.findUserWithProfileByEmail(email, req.log);
|
||||
|
||||
if (!user) {
|
||||
// User not found
|
||||
logger.warn(`Login attempt failed for non-existent user: ${email}`);
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
// Check if the account is currently locked.
|
||||
if (user.failed_login_attempts >= MAX_FAILED_ATTEMPTS && user.last_failed_login) {
|
||||
const lockoutTime = new Date(user.last_failed_login).getTime();
|
||||
const timeSinceLockout = Date.now() - lockoutTime;
|
||||
const lockoutDurationMs = LOCKOUT_DURATION_MINUTES * 60 * 1000;
|
||||
|
||||
if (timeSinceLockout < lockoutDurationMs) {
|
||||
logger.warn(`Login attempt for locked account: ${email}`);
|
||||
// Refresh the lockout timestamp on each attempt to prevent probing.
|
||||
await db.adminRepo.incrementFailedLoginAttempts(user.user.user_id, req.log);
|
||||
return done(null, false, { message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.` });
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.password_hash) {
|
||||
// User exists but signed up via OAuth, so they don't have a password.
|
||||
logger.warn(`Password login attempt for OAuth user: ${email}`);
|
||||
return done(null, false, { message: 'This account was created using a social login. Please use Google or GitHub to sign in.' });
|
||||
}
|
||||
|
||||
// 2. Compare the submitted password with the hashed password in your DB.
|
||||
logger.debug(`[Passport] Verifying password for ${email}. Hash length: ${user.password_hash.length}`);
|
||||
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||
logger.debug(`[Passport] Password match result: ${isMatch}`);
|
||||
|
||||
if (!isMatch) {
|
||||
// Password does not match
|
||||
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
||||
// Increment failed attempts and get the new count.
|
||||
const newAttemptCount = await db.adminRepo.incrementFailedLoginAttempts(user.user.user_id, req.log);
|
||||
|
||||
// Log this security event.
|
||||
await db.adminRepo.logActivity({
|
||||
userId: user.user.user_id,
|
||||
action: 'login_failed_password',
|
||||
displayText: `Failed login attempt for user ${user.user.email}.`,
|
||||
icon: 'shield-alert',
|
||||
details: { source_ip: req.ip ?? null, new_attempt_count: newAttemptCount }, // The user.email is correct here as it's part of the Omit type
|
||||
}, req.log);
|
||||
|
||||
// If this attempt just locked the account, inform the user immediately.
|
||||
if (newAttemptCount >= MAX_FAILED_ATTEMPTS) {
|
||||
return done(null, false, { message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.` });
|
||||
if (!userprofile) {
|
||||
// User not found
|
||||
logger.warn(`Login attempt failed for non-existent user: ${email}`);
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
// Check if the account is currently locked.
|
||||
if (
|
||||
userprofile.failed_login_attempts >= MAX_FAILED_ATTEMPTS &&
|
||||
userprofile.last_failed_login
|
||||
) {
|
||||
const lockoutTime = new Date(userprofile.last_failed_login).getTime();
|
||||
const timeSinceLockout = Date.now() - lockoutTime;
|
||||
const lockoutDurationMs = LOCKOUT_DURATION_MINUTES * 60 * 1000;
|
||||
|
||||
if (timeSinceLockout < lockoutDurationMs) {
|
||||
logger.warn(`Login attempt for locked account: ${email}`);
|
||||
// Refresh the lockout timestamp on each attempt to prevent probing.
|
||||
await db.adminRepo.incrementFailedLoginAttempts(userprofile.user.user_id, req.log);
|
||||
return done(null, false, {
|
||||
message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!userprofile.password_hash) {
|
||||
// User exists but signed up via OAuth, so they don't have a password.
|
||||
logger.warn(`Password login attempt for OAuth user: ${email}`);
|
||||
return done(null, false, {
|
||||
message:
|
||||
'This account was created using a social login. Please use Google or GitHub to sign in.',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Compare the submitted password with the hashed password in your DB.
|
||||
logger.debug(
|
||||
`[Passport] Verifying password for ${email}. Hash length: ${userprofile.password_hash.length}`,
|
||||
);
|
||||
const isMatch = await bcrypt.compare(password, userprofile.password_hash);
|
||||
logger.debug(`[Passport] Password match result: ${isMatch}`);
|
||||
|
||||
if (!isMatch) {
|
||||
// Password does not match
|
||||
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
||||
// Increment failed attempts and get the new count.
|
||||
const newAttemptCount = await db.adminRepo.incrementFailedLoginAttempts(
|
||||
userprofile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
|
||||
// Log this security event.
|
||||
await db.adminRepo.logActivity(
|
||||
{
|
||||
userId: userprofile.user.user_id,
|
||||
action: 'login_failed_password',
|
||||
displayText: `Failed login attempt for user ${userprofile.user.email}.`,
|
||||
icon: 'shield-alert',
|
||||
details: { source_ip: req.ip ?? null, new_attempt_count: newAttemptCount }, // The user.email is correct here as it's part of the Omit type
|
||||
},
|
||||
req.log,
|
||||
);
|
||||
|
||||
// If this attempt just locked the account, inform the user immediately.
|
||||
if (newAttemptCount >= MAX_FAILED_ATTEMPTS) {
|
||||
return done(null, false, {
|
||||
message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.`,
|
||||
});
|
||||
}
|
||||
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
// 3. Success! Return the user object (without password_hash for security).
|
||||
// Reset failed login attempts upon successful login.
|
||||
await db.adminRepo.resetFailedLoginAttempts(
|
||||
userprofile.user.user_id,
|
||||
req.ip ?? 'unknown',
|
||||
req.log,
|
||||
);
|
||||
|
||||
logger.info(`User successfully authenticated: ${email}`);
|
||||
|
||||
// The `user` object from `findUserWithProfileByEmail` is now a fully formed
|
||||
// UserProfile object with additional authentication fields. We must strip these
|
||||
// sensitive fields before passing the profile to the session.
|
||||
// The `...userProfile` rest parameter will contain the clean UserProfile object,
|
||||
// which no longer has a top-level email property.
|
||||
const {
|
||||
password_hash,
|
||||
failed_login_attempts,
|
||||
last_failed_login,
|
||||
refresh_token,
|
||||
...cleanUserProfile
|
||||
} = userprofile;
|
||||
return done(null, cleanUserProfile);
|
||||
} catch (err: unknown) {
|
||||
req.log.error({ error: err }, 'Error during local authentication strategy:');
|
||||
return done(err);
|
||||
}
|
||||
|
||||
// 3. Success! Return the user object (without password_hash for security).
|
||||
// Reset failed login attempts upon successful login.
|
||||
await db.adminRepo.resetFailedLoginAttempts(user.user.user_id, req.ip ?? 'unknown', req.log);
|
||||
|
||||
logger.info(`User successfully authenticated: ${email}`);
|
||||
|
||||
// The `user` object from `findUserWithProfileByEmail` is now a fully formed
|
||||
// UserProfile object with additional authentication fields. We must strip these
|
||||
// sensitive fields before passing the profile to the session.
|
||||
// The `...userProfile` rest parameter will contain the clean UserProfile object,
|
||||
// which no longer has a top-level email property.
|
||||
const { password_hash, failed_login_attempts, last_failed_login, refresh_token, ...userProfile } = user;
|
||||
return done(null, userProfile);
|
||||
} catch (err: unknown) {
|
||||
req.log.error({ error: err }, 'Error during local authentication strategy:');
|
||||
return done(err);
|
||||
}
|
||||
}
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// --- Passport Google OAuth 2.0 Strategy ---
|
||||
// passport.use(new GoogleStrategy({
|
||||
@@ -141,12 +171,12 @@ passport.use(new LocalStrategy(
|
||||
// // User exists, proceed to log them in.
|
||||
// logger.info(`Google OAuth successful for existing user: ${email}`);
|
||||
// // The password_hash is intentionally destructured and discarded for security.
|
||||
// const { password_hash, ...userWithoutHash } = user;
|
||||
// const { password_hash, ...userWithoutHash } = user;
|
||||
// return done(null, userWithoutHash);
|
||||
// } else {
|
||||
// // User does not exist, create a new account for them.
|
||||
// logger.info(`Google OAuth: creating new user for email: ${email}`);
|
||||
|
||||
|
||||
// // Since this is an OAuth user, they don't have a password.
|
||||
// // We pass `null` for the password hash.
|
||||
// const newUser = await db.createUser(email, null, {
|
||||
@@ -193,12 +223,12 @@ passport.use(new LocalStrategy(
|
||||
// // User exists, proceed to log them in.
|
||||
// logger.info(`GitHub OAuth successful for existing user: ${email}`);
|
||||
// // The password_hash is intentionally destructured and discarded for security.
|
||||
// const { password_hash, ...userWithoutHash } = user;
|
||||
// const { password_hash, ...userWithoutHash } = user;
|
||||
// return done(null, userWithoutHash);
|
||||
// } else {
|
||||
// // User does not exist, create a new account for them.
|
||||
// logger.info(`GitHub OAuth: creating new user for email: ${email}`);
|
||||
|
||||
|
||||
// // Since this is an OAuth user, they don't have a password.
|
||||
// // We pass `null` for the password hash.
|
||||
// const newUser = await db.createUser(email, null, {
|
||||
@@ -230,41 +260,48 @@ const jwtOptions = {
|
||||
secretOrKey: JWT_SECRET,
|
||||
};
|
||||
|
||||
passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
logger.debug({ jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' }, '[JWT Strategy] Verifying token payload:');
|
||||
try {
|
||||
// The jwt_payload contains the data you put into the token during login (e.g., { user_id: user.user_id, email: user.email }).
|
||||
// We re-fetch the user from the database here to ensure they are still active and valid.
|
||||
const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id, logger);
|
||||
passport.use(
|
||||
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
logger.debug(
|
||||
{ jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' },
|
||||
'[JWT Strategy] Verifying token payload:',
|
||||
);
|
||||
try {
|
||||
// The jwt_payload contains the data you put into the token during login (e.g., { user_id: user.user_id, email: user.email }).
|
||||
// We re-fetch the user from the database here to ensure they are still active and valid.
|
||||
const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id, logger);
|
||||
|
||||
// --- JWT STRATEGY DEBUG LOGGING ---
|
||||
logger.debug(`[JWT Strategy] DB lookup for user ID ${jwt_payload.user_id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`);
|
||||
// --- JWT STRATEGY DEBUG LOGGING ---
|
||||
logger.debug(
|
||||
`[JWT Strategy] DB lookup for user ID ${jwt_payload.user_id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`,
|
||||
);
|
||||
|
||||
if (userProfile) {
|
||||
return done(null, userProfile); // User profile object will be available as req.user in protected routes
|
||||
} else {
|
||||
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`);
|
||||
return done(null, false); // User not found or invalid token
|
||||
if (userProfile) {
|
||||
return done(null, userProfile); // User profile object will be available as req.user in protected routes
|
||||
} else {
|
||||
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`);
|
||||
return done(null, false); // User not found or invalid token
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error({ error: err }, 'Error during JWT authentication strategy:');
|
||||
return done(err, false);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error({ error: err }, 'Error during JWT authentication strategy:');
|
||||
return done(err, false);
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Middleware for Admin Role Check ---
|
||||
export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Use the type guard for safer access to req.user
|
||||
const userProfile = req.user;
|
||||
// Use the type guard for safer access to req.user
|
||||
const userProfile = req.user;
|
||||
|
||||
if (isUserProfile(userProfile) && userProfile.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
||||
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
|
||||
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||
}
|
||||
if (isUserProfile(userProfile) && userProfile.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
||||
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
|
||||
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -274,16 +311,21 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
* populated; otherwise, it will be undefined, and the request proceeds.
|
||||
*/
|
||||
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
// The custom callback for passport.authenticate gives us access to `err`, `user`, and `info`.
|
||||
passport.authenticate('jwt', { session: false }, (err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
|
||||
// If there's an authentication error (e.g., malformed token), log it but don't block the request.
|
||||
if (info) { // The patch requested this specific error handling.
|
||||
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
|
||||
} // The patch requested this specific error handling.
|
||||
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds
|
||||
|
||||
next(); // Always proceed to the next middleware
|
||||
})(req, res, next);
|
||||
// The custom callback for passport.authenticate gives us access to `err`, `user`, and `info`.
|
||||
passport.authenticate(
|
||||
'jwt',
|
||||
{ session: false },
|
||||
(err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
|
||||
// If there's an authentication error (e.g., malformed token), log it but don't block the request.
|
||||
if (info) {
|
||||
// The patch requested this specific error handling.
|
||||
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
|
||||
} // The patch requested this specific error handling.
|
||||
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds
|
||||
|
||||
next(); // Always proceed to the next middleware
|
||||
},
|
||||
)(req, res, next);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -299,16 +341,16 @@ export const optionalAuth = (req: Request, res: Response, next: NextFunction) =>
|
||||
* control to the next middleware.
|
||||
*/
|
||||
export const mockAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// In a test environment, attach a mock user to the request.
|
||||
// We use the mock factory to create a consistent, type-safe user profile.
|
||||
// We override the default role to 'admin' for broad access in tests.
|
||||
req.user = createMockUserProfile({
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
// In production or development, this middleware does nothing.
|
||||
next();
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// In a test environment, attach a mock user to the request.
|
||||
// We use the mock factory to create a consistent, type-safe user profile.
|
||||
// We override the default role to 'admin' for broad access in tests.
|
||||
req.user = createMockUserProfile({
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
// In production or development, this middleware does nothing.
|
||||
next();
|
||||
};
|
||||
|
||||
export default passport;
|
||||
export default passport;
|
||||
|
||||
@@ -18,26 +18,30 @@ import { logger as mockLoggerInstance } from './logger.server';
|
||||
// Explicitly unmock the service under test to ensure we import the real implementation.
|
||||
vi.unmock('./aiService.server');
|
||||
|
||||
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => {
|
||||
const mockGenerateContent = vi.fn();
|
||||
const mockToBuffer = vi.fn();
|
||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp };
|
||||
});
|
||||
|
||||
// Mock sharp, as it's a direct dependency of the service.
|
||||
const mockToBuffer = vi.fn();
|
||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||
vi.mock('sharp', () => ({
|
||||
__esModule: true,
|
||||
default: mockSharp,
|
||||
}));
|
||||
|
||||
// Mock @google/genai
|
||||
const mockGenerateContent = vi.fn();
|
||||
vi.mock('@google/genai', () => {
|
||||
return {
|
||||
GoogleGenAI: vi.fn(function() {
|
||||
GoogleGenAI: vi.fn(function () {
|
||||
return {
|
||||
models: {
|
||||
generateContent: mockGenerateContent
|
||||
}
|
||||
generateContent: mockGenerateContent,
|
||||
},
|
||||
};
|
||||
})
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -55,9 +59,9 @@ describe('AI Service (Server)', () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset modules to ensure the service re-initializes with the mocks
|
||||
|
||||
mockAiClient.generateContent.mockResolvedValue({
|
||||
text: '[]',
|
||||
candidates: []
|
||||
mockAiClient.generateContent.mockResolvedValue({
|
||||
text: '[]',
|
||||
candidates: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,12 +86,16 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
|
||||
console.log("TEST START: 'should throw an error if GEMINI_API_KEY is not set...'");
|
||||
console.log(`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`);
|
||||
console.log(
|
||||
`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
|
||||
);
|
||||
// Simulate a non-test environment
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
delete process.env.VITEST_POOL_ID;
|
||||
console.log(`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`);
|
||||
console.log(
|
||||
`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
|
||||
);
|
||||
|
||||
let error: Error | undefined;
|
||||
// Dynamically import the class to re-evaluate the constructor logic
|
||||
@@ -100,7 +108,9 @@ describe('AI Service (Server)', () => {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('GEMINI_API_KEY environment variable not set for server-side AI calls.');
|
||||
expect(error?.message).toBe(
|
||||
'GEMINI_API_KEY environment variable not set for server-side AI calls.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use a mock placeholder if API key is missing in a test environment', async () => {
|
||||
@@ -113,40 +123,46 @@ describe('AI Service (Server)', () => {
|
||||
const service = new AIService(mockLoggerInstance);
|
||||
|
||||
// Assert: Check that the warning was logged and the mock client is in use
|
||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith('[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.');
|
||||
await expect((service as any).aiClient.generateContent({ contents: [] })).resolves.toBeDefined();
|
||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
||||
'[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.',
|
||||
);
|
||||
await expect(
|
||||
(service as any).aiClient.generateContent({ contents: [] }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => {
|
||||
process.env.GEMINI_API_KEY = 'test-key';
|
||||
// We need to force the constructor to use the real client logic, not the injected mock.
|
||||
// So we instantiate AIService without passing aiClient.
|
||||
|
||||
|
||||
// Reset modules to pick up the mock for @google/genai
|
||||
vi.resetModules();
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const service = new AIService(mockLoggerInstance);
|
||||
|
||||
|
||||
// Access the private aiClient (which is now the adapter)
|
||||
const adapter = (service as any).aiClient;
|
||||
|
||||
|
||||
const request = { contents: [{ parts: [{ text: 'test' }] }] };
|
||||
await adapter.generateContent(request);
|
||||
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({
|
||||
model: 'gemini-2.5-flash',
|
||||
...request
|
||||
...request,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should throw error if adapter is called without content', async () => {
|
||||
process.env.GEMINI_API_KEY = 'test-key';
|
||||
vi.resetModules();
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const service = new AIService(mockLoggerInstance);
|
||||
const adapter = (service as any).aiClient;
|
||||
|
||||
await expect(adapter.generateContent({})).rejects.toThrow('AIService.generateContent requires at least one content element.');
|
||||
process.env.GEMINI_API_KEY = 'test-key';
|
||||
vi.resetModules();
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const service = new AIService(mockLoggerInstance);
|
||||
const adapter = (service as any).aiClient;
|
||||
|
||||
await expect(adapter.generateContent({})).rejects.toThrow(
|
||||
'AIService.generateContent requires at least one content element.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,11 +172,15 @@ describe('AI Service (Server)', () => {
|
||||
{ "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 },
|
||||
{ "raw_item_description": "AVOCADO", "price_paid_cents": 299 }
|
||||
]`;
|
||||
|
||||
|
||||
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', mockLoggerInstance);
|
||||
const result = await aiServiceInstance.extractItemsFromReceiptImage(
|
||||
'path/to/image.jpg',
|
||||
'image/jpeg',
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([
|
||||
@@ -173,9 +193,13 @@ 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', mockLoggerInstance)).rejects.toThrow(
|
||||
'AI response did not contain a valid JSON array.'
|
||||
);
|
||||
await expect(
|
||||
aiServiceInstance.extractItemsFromReceiptImage(
|
||||
'path/to/image.jpg',
|
||||
'image/jpeg',
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow('AI response did not contain a valid JSON array.');
|
||||
});
|
||||
|
||||
it('should throw an error if the AI API call fails', async () => {
|
||||
@@ -183,16 +207,24 @@ 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', mockLoggerInstance))
|
||||
.rejects.toThrow(apiError);
|
||||
await expect(
|
||||
aiServiceInstance.extractItemsFromReceiptImage(
|
||||
'path/to/image.jpg',
|
||||
'image/jpeg',
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow(apiError);
|
||||
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||
{ err: apiError }, "[extractItemsFromReceiptImage] An error occurred during the process."
|
||||
{ err: apiError },
|
||||
'[extractItemsFromReceiptImage] An error occurred during the process.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCoreDataFromFlyerImage', () => {
|
||||
const mockMasterItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
|
||||
const mockMasterItems: MasterGroceryItem[] = [
|
||||
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
|
||||
];
|
||||
|
||||
it('should extract and post-process flyer data correctly', async () => {
|
||||
const mockAiResponse = {
|
||||
@@ -200,14 +232,37 @@ describe('AI Service (Server)', () => {
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
items: [
|
||||
{ item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', category_name: 'Produce', master_item_id: 1 },
|
||||
{ item: 'Oranges', price_display: null, price_in_cents: null, quantity: undefined, category_name: null, master_item_id: null },
|
||||
{
|
||||
item: 'Apples',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: '1lb',
|
||||
category_name: 'Produce',
|
||||
master_item_id: 1,
|
||||
},
|
||||
{
|
||||
item: 'Oranges',
|
||||
price_display: null,
|
||||
price_in_cents: null,
|
||||
quantity: undefined,
|
||||
category_name: null,
|
||||
master_item_id: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockAiClient.generateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse), candidates: [] });
|
||||
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, undefined, undefined, mockLoggerInstance);
|
||||
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');
|
||||
@@ -221,20 +276,36 @@ 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, undefined, undefined, mockLoggerInstance)).rejects.toThrow(
|
||||
'AI response did not contain a valid JSON object.'
|
||||
);
|
||||
await expect(
|
||||
aiServiceInstance.extractCoreDataFromFlyerImage(
|
||||
[],
|
||||
mockMasterItems,
|
||||
undefined,
|
||||
undefined,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow('AI response did not contain a valid JSON object.');
|
||||
});
|
||||
|
||||
it('should throw an error if the AI response contains malformed JSON', async () => {
|
||||
console.log("TEST START: 'should throw an error if the AI response contains malformed JSON'");
|
||||
// Arrange: AI returns a string that looks like JSON but is invalid
|
||||
mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] });
|
||||
mockAiClient.generateContent.mockResolvedValue({
|
||||
text: '{ "store_name": "Incomplete, }',
|
||||
candidates: [],
|
||||
});
|
||||
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
|
||||
|
||||
// Act & Assert
|
||||
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance))
|
||||
.rejects.toThrow('AI response did not contain a valid JSON object.');
|
||||
await expect(
|
||||
aiServiceInstance.extractCoreDataFromFlyerImage(
|
||||
[],
|
||||
mockMasterItems,
|
||||
undefined,
|
||||
undefined,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow('AI response did not contain a valid JSON object.');
|
||||
});
|
||||
|
||||
it('should throw an error if the AI API call fails', async () => {
|
||||
@@ -244,48 +315,103 @@ describe('AI Service (Server)', () => {
|
||||
mockAiClient.generateContent.mockRejectedValue(apiError);
|
||||
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
|
||||
|
||||
// Act & Assert
|
||||
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(apiError);
|
||||
expect(mockLoggerInstance.error).toHaveBeenCalledWith({ err: apiError },
|
||||
"[extractCoreDataFromFlyerImage] The entire process failed."
|
||||
// Act & Assert
|
||||
await expect(
|
||||
aiServiceInstance.extractCoreDataFromFlyerImage(
|
||||
[],
|
||||
mockMasterItems,
|
||||
undefined,
|
||||
undefined,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow(apiError);
|
||||
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||
{ err: apiError },
|
||||
'[extractCoreDataFromFlyerImage] The entire process failed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildFlyerExtractionPrompt (private method)', () => {
|
||||
it('should include a strong hint for userProfileAddress', () => {
|
||||
const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: undefined, userProfileAddress: string) => string })._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown');
|
||||
expect(prompt).toContain('The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.');
|
||||
const prompt = (
|
||||
aiServiceInstance as unknown as {
|
||||
_buildFlyerExtractionPrompt: (
|
||||
masterItems: [],
|
||||
submitterIp: undefined,
|
||||
userProfileAddress: string,
|
||||
) => string;
|
||||
}
|
||||
)._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown');
|
||||
expect(prompt).toContain(
|
||||
'The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include a general hint for submitterIp when no address is present', () => {
|
||||
const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string })._buildFlyerExtractionPrompt([], '123.45.67.89');
|
||||
expect(prompt).toContain('The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store\'s region.');
|
||||
const prompt = (
|
||||
aiServiceInstance as unknown as {
|
||||
_buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string;
|
||||
}
|
||||
)._buildFlyerExtractionPrompt([], '123.45.67.89');
|
||||
expect(prompt).toContain(
|
||||
"The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include any location hint if no IP or address is provided', () => {
|
||||
const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string })._buildFlyerExtractionPrompt([]);
|
||||
const prompt = (
|
||||
aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string }
|
||||
)._buildFlyerExtractionPrompt([]);
|
||||
expect(prompt).not.toContain('Use this as a strong hint');
|
||||
expect(prompt).not.toContain('Use this as a general hint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_parseJsonFromAiResponse (private method)', () => {
|
||||
it('should return null for undefined or empty input', () => { // This was a duplicate, fixed.
|
||||
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(undefined, mockLoggerInstance)).toBeNull();
|
||||
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse('', mockLoggerInstance)).toBeNull();
|
||||
it('should return null for undefined or empty input', () => {
|
||||
// This was a duplicate, fixed.
|
||||
expect(
|
||||
(
|
||||
aiServiceInstance as unknown as {
|
||||
_parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null;
|
||||
}
|
||||
)._parseJsonFromAiResponse(undefined, mockLoggerInstance),
|
||||
).toBeNull();
|
||||
expect(
|
||||
(
|
||||
aiServiceInstance as unknown as {
|
||||
_parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
|
||||
}
|
||||
)._parseJsonFromAiResponse('', mockLoggerInstance),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should correctly parse a clean JSON string', () => {
|
||||
const json = '{ "key": "value" }';
|
||||
// Use a type-safe assertion to access the private method for testing.
|
||||
const result = (aiServiceInstance as unknown as { _parseJsonFromAiResponse: <T>(text: string, logger: Logger) => T | null })._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance);
|
||||
const result = (
|
||||
aiServiceInstance as unknown as {
|
||||
_parseJsonFromAiResponse: <T>(text: string, logger: Logger) => T | null;
|
||||
}
|
||||
)._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance);
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should extract and parse JSON wrapped in markdown and other text', () => { // This was a duplicate, fixed.
|
||||
const responseText = 'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
|
||||
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => { data: boolean } })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual({ data: true });
|
||||
it('should extract and parse JSON wrapped in markdown and other text', () => {
|
||||
// This was a duplicate, fixed.
|
||||
const responseText =
|
||||
'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
|
||||
expect(
|
||||
(
|
||||
aiServiceInstance as unknown as {
|
||||
_parseJsonFromAiResponse: (
|
||||
text: string,
|
||||
logger: typeof mockLoggerInstance,
|
||||
) => { data: boolean };
|
||||
}
|
||||
)._parseJsonFromAiResponse(responseText, mockLoggerInstance),
|
||||
).toEqual({ data: true });
|
||||
});
|
||||
|
||||
it('should handle JSON arrays correctly', () => {
|
||||
@@ -295,7 +421,10 @@ describe('AI Service (Server)', () => {
|
||||
// --- FULL DIAGNOSTIC LOGGING REMAINS FOR PROOF ---
|
||||
console.log('\n--- TEST LOG: "should handle JSON arrays correctly" ---');
|
||||
console.log(' - Test Input String:', JSON.stringify(responseText));
|
||||
const result = (aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance);
|
||||
const result = (aiServiceInstance as any)._parseJsonFromAiResponse(
|
||||
responseText,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
console.log(' - Actual Output from function:', JSON.stringify(result));
|
||||
console.log(' - Expected Output:', JSON.stringify([1, 2, 3]));
|
||||
console.log('--- END TEST LOG ---\n');
|
||||
@@ -304,22 +433,58 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
it('should return null for strings without valid JSON', () => {
|
||||
const responseText = 'This is just plain text.';
|
||||
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toBeNull();
|
||||
expect(
|
||||
(
|
||||
aiServiceInstance as unknown as {
|
||||
_parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
|
||||
}
|
||||
)._parseJsonFromAiResponse(responseText, mockLoggerInstance),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for incomplete JSON and log an error', () => {
|
||||
const localLogger = createMockLogger();
|
||||
const localAiServiceInstance = new AIService(localLogger, mockAiClient, mockFileSystem);
|
||||
const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
|
||||
expect((localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger)).toBeNull(); // This was a duplicate, fixed.
|
||||
expect(localLogger.error).toHaveBeenCalledWith(expect.objectContaining({ jsonSlice: '{ "key": "value"' }), "[_parseJsonFromAiResponse] Failed to parse JSON slice.");
|
||||
expect(
|
||||
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
|
||||
).toBeNull(); // This was a duplicate, fixed.
|
||||
expect(localLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
|
||||
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_normalizeExtractedItems (private method)', () => {
|
||||
it('should replace null or undefined fields with default values', () => {
|
||||
const rawItems: { item: string; price_display: null; quantity: undefined; category_name: null; master_item_id: null; }[] = [{ item: 'Test', price_display: null, quantity: undefined, category_name: null, master_item_id: null }];
|
||||
const [normalized] = (aiServiceInstance as unknown as { _normalizeExtractedItems: (items: typeof rawItems) => { price_display: string, quantity: string, category_name: string, master_item_id: undefined }[] })._normalizeExtractedItems(rawItems);
|
||||
const rawItems: {
|
||||
item: string;
|
||||
price_display: null;
|
||||
quantity: undefined;
|
||||
category_name: null;
|
||||
master_item_id: null;
|
||||
}[] = [
|
||||
{
|
||||
item: 'Test',
|
||||
price_display: null,
|
||||
quantity: undefined,
|
||||
category_name: null,
|
||||
master_item_id: null,
|
||||
},
|
||||
];
|
||||
const [normalized] = (
|
||||
aiServiceInstance as unknown as {
|
||||
_normalizeExtractedItems: (
|
||||
items: typeof rawItems,
|
||||
) => {
|
||||
price_display: string;
|
||||
quantity: string;
|
||||
category_name: string;
|
||||
master_item_id: undefined;
|
||||
}[];
|
||||
}
|
||||
)._normalizeExtractedItems(rawItems);
|
||||
expect(normalized.price_display).toBe('');
|
||||
expect(normalized.quantity).toBe('');
|
||||
expect(normalized.category_name).toBe('Other/Miscellaneous');
|
||||
@@ -340,7 +505,13 @@ describe('AI Service (Server)', () => {
|
||||
// Mock AI response
|
||||
mockAiClient.generateContent.mockResolvedValue({ text: 'Super Store', candidates: [] });
|
||||
|
||||
const result = await aiServiceInstance.extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType, mockLoggerInstance);
|
||||
const result = await aiServiceInstance.extractTextFromImageArea(
|
||||
imagePath,
|
||||
'image/jpeg',
|
||||
cropArea,
|
||||
extractionType,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(mockSharp).toHaveBeenCalledWith(imagePath);
|
||||
expect(mockExtract).toHaveBeenCalledWith({
|
||||
@@ -351,7 +522,7 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
|
||||
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
interface AiCallArgs {
|
||||
contents: {
|
||||
parts: {
|
||||
@@ -361,37 +532,59 @@ describe('AI Service (Server)', () => {
|
||||
}[];
|
||||
}
|
||||
const aiCallArgs = mockAiClient.generateContent.mock.calls[0][0] as AiCallArgs;
|
||||
expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?');
|
||||
expect(aiCallArgs.contents[0].parts[0].text).toContain(
|
||||
'What is the store name in this image?',
|
||||
);
|
||||
expect(result.text).toBe('Super Store');
|
||||
});
|
||||
|
||||
it('should throw an error if the AI API call fails', async () => {
|
||||
console.log("TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)");
|
||||
console.log(
|
||||
"TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)",
|
||||
);
|
||||
const apiError = new Error('API Error');
|
||||
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', mockLoggerInstance))
|
||||
.rejects.toThrow(apiError);
|
||||
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 }, `[extractTextFromImageArea] An error occurred for type dates.`
|
||||
{ err: apiError },
|
||||
`[extractTextFromImageArea] An error occurred for type dates.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTripWithMaps', () => {
|
||||
const mockUserLocation: GeolocationCoordinates = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: () => ({}) };
|
||||
const mockUserLocation: GeolocationCoordinates = {
|
||||
latitude: 45,
|
||||
longitude: -75,
|
||||
accuracy: 10,
|
||||
altitude: null,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
const mockStore = { name: 'Test Store' };
|
||||
|
||||
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, mockLoggerInstance))
|
||||
.rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
|
||||
|
||||
await expect(
|
||||
aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance),
|
||||
).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
|
||||
|
||||
// Also verify that the warning is logged
|
||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
||||
"[AIService] planTripWithMaps called, but feature is disabled. Throwing error."
|
||||
'[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export class BackgroundJobService {
|
||||
private personalizationRepo: PersonalizationRepository,
|
||||
private notificationRepo: NotificationRepository, // Use the imported type here
|
||||
private emailQueue: Queue<EmailJobData>,
|
||||
private logger: Logger
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,19 +29,27 @@ export class BackgroundJobService {
|
||||
* @param deals The list of deals found for the user.
|
||||
* @returns An object containing the email job data and a unique job ID.
|
||||
*/
|
||||
private _prepareDealEmail(user: { user_id: string; email: string; full_name: string | null }, deals: WatchedItemDeal[]): { jobData: EmailJobData; jobId: string } {
|
||||
const recipientName = user.full_name || 'there';
|
||||
private _prepareDealEmail(
|
||||
userProfile: { user_id: string; email: string; full_name: string | null },
|
||||
deals: WatchedItemDeal[],
|
||||
): { jobData: EmailJobData; jobId: string } {
|
||||
const recipientName = userProfile.full_name || 'there';
|
||||
const subject = `New Deals Found on Your Watched Items!`;
|
||||
const dealsListHtml = deals.map(deal => `<li><strong>${deal.item_name}</strong> is on sale for <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong> at ${deal.store_name}!</li>`).join('');
|
||||
const dealsListHtml = deals
|
||||
.map(
|
||||
(deal) =>
|
||||
`<li><strong>${deal.item_name}</strong> is on sale for <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong> at ${deal.store_name}!</li>`,
|
||||
)
|
||||
.join('');
|
||||
const html = `<p>Hi ${recipientName},</p><p>We found some great deals on items you're watching:</p><ul>${dealsListHtml}</ul>`;
|
||||
const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.`;
|
||||
|
||||
// Use a predictable Job ID to prevent duplicate email notifications for the same user on the same day.
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const jobId = `deal-email-${user.user_id}-${today}`;
|
||||
const jobId = `deal-email-${userProfile.user_id}-${today}`;
|
||||
|
||||
return {
|
||||
jobData: { to: user.email, subject, html, text },
|
||||
jobData: { to: userProfile.email, subject, html, text },
|
||||
jobId,
|
||||
};
|
||||
}
|
||||
@@ -52,7 +60,10 @@ export class BackgroundJobService {
|
||||
* @param dealCount The number of deals found.
|
||||
* @returns The notification object ready for database insertion.
|
||||
*/
|
||||
private _prepareInAppNotification(userId: string, dealCount: number): Omit<Notification, 'notification_id' | 'is_read' | 'created_at'> {
|
||||
private _prepareInAppNotification(
|
||||
userId: string,
|
||||
dealCount: number,
|
||||
): Omit<Notification, 'notification_id' | 'is_read' | 'created_at'> {
|
||||
return {
|
||||
user_id: userId,
|
||||
content: `You have ${dealCount} new deal(s) on your watched items!`,
|
||||
@@ -61,10 +72,10 @@ export class BackgroundJobService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for new deals on watched items for all users and sends notifications.
|
||||
* This function is designed to be run periodically (e.g., daily).
|
||||
*/
|
||||
/**
|
||||
* Checks for new deals on watched items for all users and sends notifications.
|
||||
* This function is designed to be run periodically (e.g., daily).
|
||||
*/
|
||||
async runDailyDealCheck(): Promise<void> {
|
||||
this.logger.info('[BackgroundJob] Starting daily deal check for all users...');
|
||||
|
||||
@@ -80,44 +91,60 @@ export class BackgroundJobService {
|
||||
this.logger.info(`[BackgroundJob] Found ${allDeals.length} total deals across all users.`);
|
||||
|
||||
// 2. Group deals by user in memory.
|
||||
const dealsByUser = allDeals.reduce<Record<string, { user: { user_id: string; email: string; full_name: string | null }; deals: WatchedItemDeal[] }>>((acc, deal) => {
|
||||
const dealsByUser = allDeals.reduce<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
userProfile: { user_id: string; email: string; full_name: string | null };
|
||||
deals: WatchedItemDeal[];
|
||||
}
|
||||
>
|
||||
>((acc, deal) => {
|
||||
if (!acc[deal.user_id]) {
|
||||
acc[deal.user_id] = {
|
||||
user: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
|
||||
deals: []
|
||||
userProfile: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
|
||||
deals: [],
|
||||
};
|
||||
}
|
||||
acc[deal.user_id].deals.push(deal);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const allNotifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[] = [];
|
||||
const allNotifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[] =
|
||||
[];
|
||||
|
||||
// 3. Process each user's deals in parallel.
|
||||
const userProcessingPromises = Object.values(dealsByUser).map(async ({ user, deals }) => {
|
||||
try {
|
||||
this.logger.info(`[BackgroundJob] Found ${deals.length} deals for user ${user.user_id}.`);
|
||||
const userProcessingPromises = Object.values(dealsByUser).map(
|
||||
async ({ userProfile, deals }) => {
|
||||
try {
|
||||
this.logger.info(
|
||||
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
|
||||
);
|
||||
|
||||
// 4. Prepare in-app and email notifications.
|
||||
const notification = this._prepareInAppNotification(user.user_id, deals.length);
|
||||
const { jobData, jobId } = this._prepareDealEmail(user, deals);
|
||||
// 4. Prepare in-app and email notifications.
|
||||
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
|
||||
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
|
||||
|
||||
// 5. Enqueue an email notification job.
|
||||
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
|
||||
// 5. Enqueue an email notification job.
|
||||
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
|
||||
|
||||
// Return the notification to be collected for bulk insertion.
|
||||
return notification;
|
||||
} catch (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.
|
||||
}
|
||||
});
|
||||
// Return the notification to be collected for bulk insertion.
|
||||
return notification;
|
||||
} catch (userError) {
|
||||
this.logger.error(
|
||||
{ err: userError },
|
||||
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
|
||||
);
|
||||
return null; // Return null on error for this user.
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for all user processing to complete.
|
||||
const results = await Promise.allSettled(userProcessingPromises);
|
||||
|
||||
// 6. Collect all successfully created notifications.
|
||||
results.forEach(result => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
allNotifications.push(result.value);
|
||||
}
|
||||
@@ -126,12 +153,17 @@ export class BackgroundJobService {
|
||||
// 7. Bulk insert all in-app notifications in a single query.
|
||||
if (allNotifications.length > 0) {
|
||||
await this.notificationRepo.createBulkNotifications(allNotifications, this.logger);
|
||||
this.logger.info(`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`);
|
||||
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({ err: error }, '[BackgroundJob] A critical error occurred during the daily deal check');
|
||||
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;
|
||||
}
|
||||
@@ -152,7 +184,7 @@ export function startBackgroundJobs(
|
||||
analyticsQueue: Queue,
|
||||
weeklyAnalyticsQueue: Queue,
|
||||
tokenCleanupQueue: Queue,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
): void {
|
||||
try {
|
||||
// Schedule the deal check job to run once every day at 2:00 AM.
|
||||
@@ -160,7 +192,9 @@ export function startBackgroundJobs(
|
||||
// Self-invoking async function to handle the promise and errors gracefully.
|
||||
(async () => {
|
||||
if (isDailyDealCheckRunning) {
|
||||
logger.warn('[BackgroundJob] Daily deal check is already running. Skipping this scheduled run.');
|
||||
logger.warn(
|
||||
'[BackgroundJob] Daily deal check is already running. Skipping this scheduled run.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
isDailyDealCheckRunning = true;
|
||||
@@ -168,13 +202,19 @@ export function startBackgroundJobs(
|
||||
await backgroundJobService.runDailyDealCheck();
|
||||
} catch (error) {
|
||||
// The method itself logs details, this is a final catch-all.
|
||||
logger.error({ err: error }, '[BackgroundJob] Cron job for daily deal check failed unexpectedly.');
|
||||
logger.error(
|
||||
{ err: error },
|
||||
'[BackgroundJob] Cron job for daily deal check failed unexpectedly.',
|
||||
);
|
||||
} finally {
|
||||
isDailyDealCheckRunning = false;
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
// This catch is for unhandled promise rejections from the async wrapper itself.
|
||||
logger.error({ error }, '[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.');
|
||||
logger.error(
|
||||
{ error },
|
||||
'[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.',
|
||||
);
|
||||
isDailyDealCheckRunning = false;
|
||||
});
|
||||
});
|
||||
@@ -187,33 +227,48 @@ export function startBackgroundJobs(
|
||||
try {
|
||||
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
// We use a unique job ID to prevent duplicate jobs for the same day if the scheduler restarts.
|
||||
await analyticsQueue.add('generate-daily-report', { reportDate }, {
|
||||
jobId: `daily-report-${reportDate}`
|
||||
});
|
||||
await analyticsQueue.add(
|
||||
'generate-daily-report',
|
||||
{ reportDate },
|
||||
{
|
||||
jobId: `daily-report-${reportDate}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[BackgroundJob] Failed to enqueue daily analytics job.');
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Unhandled rejection in analytics report cron wrapper.');
|
||||
logger.error(
|
||||
{ err: error },
|
||||
'[BackgroundJob] Unhandled rejection in analytics report cron wrapper.',
|
||||
);
|
||||
});
|
||||
});
|
||||
logger.info('[BackgroundJob] Cron job for daily analytics reports has been scheduled.');
|
||||
|
||||
// Schedule the weekly analytics report generation job to run every Sunday at 4:00 AM.
|
||||
cron.schedule('0 4 * * 0', () => { // 0 4 * * 0 means 4:00 AM on Sunday
|
||||
cron.schedule('0 4 * * 0', () => {
|
||||
// 0 4 * * 0 means 4:00 AM on Sunday
|
||||
(async () => {
|
||||
logger.info('[BackgroundJob] Enqueuing weekly analytics report generation job.');
|
||||
try {
|
||||
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
|
||||
|
||||
await weeklyAnalyticsQueue.add('generate-weekly-report', { reportYear, reportWeek }, {
|
||||
jobId: `weekly-report-${reportYear}-${reportWeek}`
|
||||
});
|
||||
await weeklyAnalyticsQueue.add(
|
||||
'generate-weekly-report',
|
||||
{ reportYear, reportWeek },
|
||||
{
|
||||
jobId: `weekly-report-${reportYear}-${reportWeek}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[BackgroundJob] Failed to enqueue weekly analytics job.');
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.');
|
||||
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.');
|
||||
@@ -224,19 +279,29 @@ export function startBackgroundJobs(
|
||||
logger.info('[BackgroundJob] Enqueuing expired password reset token cleanup job.');
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
await tokenCleanupQueue.add('cleanup-tokens', { timestamp }, {
|
||||
jobId: `token-cleanup-${timestamp.split('T')[0]}`
|
||||
});
|
||||
await tokenCleanupQueue.add(
|
||||
'cleanup-tokens',
|
||||
{ timestamp },
|
||||
{
|
||||
jobId: `token-cleanup-${timestamp.split('T')[0]}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[BackgroundJob] Failed to enqueue token cleanup job.');
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Unhandled rejection in token cleanup cron wrapper.');
|
||||
logger.error(
|
||||
{ err: error },
|
||||
'[BackgroundJob] Unhandled rejection in token cleanup cron wrapper.',
|
||||
);
|
||||
});
|
||||
});
|
||||
logger.info('[BackgroundJob] Cron job for expired token cleanup has been scheduled.');
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.');
|
||||
logger.error(
|
||||
{ err: error },
|
||||
'[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,4 +315,4 @@ export const backgroundJobService = new BackgroundJobService(
|
||||
notificationRepo,
|
||||
emailQueue,
|
||||
logger,
|
||||
);
|
||||
);
|
||||
|
||||
@@ -33,13 +33,19 @@ import type { Profile, ActivityLogItem, SearchQuery, UserProfile } from '../../t
|
||||
// Update mocks to put methods on prototype so spyOn works in exportUserData tests
|
||||
vi.mock('./shopping.db', () => ({
|
||||
ShoppingRepository: class {
|
||||
getShoppingLists() { return Promise.resolve([]); }
|
||||
createShoppingList() { return Promise.resolve({}); }
|
||||
getShoppingLists() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
createShoppingList() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock('./personalization.db', () => ({
|
||||
PersonalizationRepository: class {
|
||||
getWatchedItems() { return Promise.resolve([]); }
|
||||
getWatchedItems() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,7 +62,10 @@ describe('User DB Service', () => {
|
||||
vi.clearAllMocks();
|
||||
userRepo = new UserRepository(mockPoolInstance as unknown as PoolClient);
|
||||
// Provide a default mock implementation for withTransaction for all tests.
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback: (client: PoolClient) => Promise<unknown>) => callback(mockPoolInstance as unknown as PoolClient));
|
||||
vi.mocked(withTransaction).mockImplementation(
|
||||
async (callback: (client: PoolClient) => Promise<unknown>) =>
|
||||
callback(mockPoolInstance as unknown as PoolClient),
|
||||
);
|
||||
});
|
||||
|
||||
describe('findUserByEmail', () => {
|
||||
@@ -66,22 +75,33 @@ describe('User DB Service', () => {
|
||||
|
||||
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(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.users WHERE email = $1'),
|
||||
['test@example.com'],
|
||||
);
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should return undefined if user is not found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
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(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.users WHERE email = $1'),
|
||||
['notfound@example.com'],
|
||||
);
|
||||
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.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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,8 +111,15 @@ describe('User DB Service', () => {
|
||||
const now = new Date().toISOString();
|
||||
// This is the flat structure returned by the DB query inside createUser
|
||||
const mockDbProfile = {
|
||||
user_id: 'new-user-id', email: 'new@example.com', role: 'user', full_name: 'New User',
|
||||
avatar_url: null, points: 0, preferences: null, created_at: now, updated_at: now
|
||||
user_id: 'new-user-id',
|
||||
email: 'new@example.com',
|
||||
role: 'user',
|
||||
full_name: 'New User',
|
||||
avatar_url: null,
|
||||
points: 0,
|
||||
preferences: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
// This is the nested structure the function is expected to return
|
||||
const expectedProfile: UserProfile = {
|
||||
@@ -115,15 +142,26 @@ describe('User DB Service', () => {
|
||||
return callback(mockClient as unknown as PoolClient);
|
||||
});
|
||||
|
||||
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' }, mockLogger);
|
||||
const result = await userRepo.createUser(
|
||||
'new@example.com',
|
||||
'hashedpass',
|
||||
{ full_name: 'New User' },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
console.log('[TEST DEBUG] createUser - Result from function:', JSON.stringify(result, null, 2));
|
||||
console.log('[TEST DEBUG] createUser - Expected result:', JSON.stringify(expectedProfile, null, 2));
|
||||
console.log(
|
||||
'[TEST DEBUG] createUser - Result from function:',
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
console.log(
|
||||
'[TEST DEBUG] createUser - Expected result:',
|
||||
JSON.stringify(expectedProfile, null, 2),
|
||||
);
|
||||
|
||||
// Use objectContaining because the real implementation might have other DB-generated fields.
|
||||
expect(result).toEqual(expect.objectContaining(expectedProfile));
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback the transaction if creating the user fails', async () => {
|
||||
const dbError = new Error('User insert failed');
|
||||
@@ -134,8 +172,13 @@ describe('User DB Service', () => {
|
||||
throw dbError;
|
||||
});
|
||||
|
||||
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');
|
||||
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 () => {
|
||||
@@ -151,8 +194,13 @@ describe('User DB Service', () => {
|
||||
throw dbError;
|
||||
});
|
||||
|
||||
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');
|
||||
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 () => {
|
||||
@@ -174,7 +222,9 @@ describe('User DB Service', () => {
|
||||
}
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(`Attempted to create a user with an existing email: exists@example.com`);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
`Attempted to create a user with an existing email: exists@example.com`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if profile is not found after user creation', async () => {
|
||||
@@ -187,12 +237,19 @@ describe('User DB Service', () => {
|
||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds
|
||||
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
||||
// The callback will throw, which is caught and re-thrown by withTransaction
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow('Failed to create or retrieve user profile after registration.');
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
|
||||
'Failed to create or retrieve user profile after registration.',
|
||||
);
|
||||
throw new Error('Internal failure'); // Simulate re-throw from withTransaction
|
||||
});
|
||||
|
||||
await expect(userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger)).rejects.toThrow('Failed to create user in database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), email: 'no-profile@example.com' }, 'Error during createUser transaction');
|
||||
await expect(
|
||||
userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger),
|
||||
).rejects.toThrow('Failed to create user in database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), email: 'no-profile@example.com' },
|
||||
'Error during createUser transaction',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,7 +275,6 @@ describe('User DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockDbResult] });
|
||||
|
||||
const expectedResult = {
|
||||
user_id: '123',
|
||||
full_name: 'Test User',
|
||||
avatar_url: null,
|
||||
role: 'user',
|
||||
@@ -228,7 +284,6 @@ describe('User DB Service', () => {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
user: { user_id: '123', email: 'test@example.com' },
|
||||
email: 'test@example.com',
|
||||
password_hash: 'hash',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null,
|
||||
@@ -237,10 +292,19 @@ describe('User DB Service', () => {
|
||||
|
||||
const result = await userRepo.findUserWithProfileByEmail('test@example.com', mockLogger);
|
||||
|
||||
console.log('[TEST DEBUG] findUserWithProfileByEmail - Result from function:', JSON.stringify(result, null, 2));
|
||||
console.log('[TEST DEBUG] findUserWithProfileByEmail - Expected result:', JSON.stringify(expectedResult, null, 2));
|
||||
console.log(
|
||||
'[TEST DEBUG] findUserWithProfileByEmail - Result from function:',
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
console.log(
|
||||
'[TEST DEBUG] findUserWithProfileByEmail - Expected result:',
|
||||
JSON.stringify(expectedResult, null, 2),
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('JOIN public.profiles'), ['test@example.com']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('JOIN public.profiles'),
|
||||
['test@example.com'],
|
||||
);
|
||||
expect(result).toEqual(expect.objectContaining(expectedResult));
|
||||
});
|
||||
|
||||
@@ -253,8 +317,13 @@ 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.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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,40 +331,65 @@ describe('User DB Service', () => {
|
||||
it('should query for a user by their ID', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }], rowCount: 1 });
|
||||
await userRepo.findUserById('123', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']);
|
||||
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: [], rowCount: 0 });
|
||||
await expect(userRepo.findUserById('not-found-id', mockLogger)).rejects.toThrow('User with ID not-found-id not found.');
|
||||
await expect(userRepo.findUserById('not-found-id', mockLogger)).rejects.toThrow(
|
||||
'User with ID not-found-id 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(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');
|
||||
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' }], rowCount: 1 });
|
||||
mockPoolInstance.query.mockResolvedValue({
|
||||
rows: [{ user_id: '123', password_hash: 'hash' }],
|
||||
rowCount: 1,
|
||||
});
|
||||
await userRepo.findUserWithPasswordHashById('123', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT user_id, email, password_hash'), ['123']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT user_id, email, password_hash'),
|
||||
['123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if user is not found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
await expect(userRepo.findUserWithPasswordHashById('not-found-id', mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(userRepo.findUserWithPasswordHashById('not-found-id', mockLogger)).rejects.toThrow('User with ID not-found-id not found.');
|
||||
await expect(
|
||||
userRepo.findUserWithPasswordHashById('not-found-id', mockLogger),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
await expect(
|
||||
userRepo.findUserWithPasswordHashById('not-found-id', mockLogger),
|
||||
).rejects.toThrow('User with ID not-found-id 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(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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -304,52 +398,92 @@ describe('User DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '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']);
|
||||
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.findUserProfileById('not-found-id', mockLogger)).rejects.toThrow('Profile not found for this user.');
|
||||
await expect(userRepo.findUserProfileById('not-found-id', mockLogger)).rejects.toThrow(
|
||||
'Profile not found for this user.',
|
||||
);
|
||||
});
|
||||
|
||||
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', mockLogger)).rejects.toThrow('Failed to retrieve user profile from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserProfileById');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserProfile', () => {
|
||||
it('should execute an UPDATE query for the user profile', async () => {
|
||||
const mockProfile: Profile = { full_name: 'Updated Name', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
const mockProfile: Profile = {
|
||||
full_name: 'Updated Name',
|
||||
role: 'user',
|
||||
points: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||
|
||||
await userRepo.updateUserProfile('123', { full_name: 'Updated Name' }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.profiles'), expect.any(Array));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.profiles'),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute an UPDATE query for avatar_url', async () => {
|
||||
const mockProfile: Profile = { avatar_url: 'new-avatar.png', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
const mockProfile: Profile = {
|
||||
avatar_url: 'new-avatar.png',
|
||||
role: 'user',
|
||||
points: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||
|
||||
await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('avatar_url = $1'), ['new-avatar.png', '123']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('avatar_url = $1'),
|
||||
['new-avatar.png', '123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute an UPDATE query for address_id', async () => {
|
||||
const mockProfile: Profile = { address_id: 99, role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
const mockProfile: Profile = {
|
||||
address_id: 99,
|
||||
role: 'user',
|
||||
points: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||
|
||||
await userRepo.updateUserProfile('123', { address_id: 99 }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('address_id = $1'), [99, '123']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('address_id = $1'),
|
||||
[99, '123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch the current profile if no update fields are provided', async () => {
|
||||
const mockProfile: Profile = createMockUserProfile({ user: { user_id: '123', email: '123@example.com' }, full_name: 'Current Name' });
|
||||
const mockProfile: Profile = createMockUserProfile({
|
||||
user: { user_id: '123', email: '123@example.com' },
|
||||
full_name: 'Current Name',
|
||||
});
|
||||
// FIX: Instead of mocking `mockResolvedValue` on the instance method which might fail if not spied correctly,
|
||||
// we mock the underlying `db.query` call that `findUserProfileById` makes.
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||
@@ -357,20 +491,30 @@ describe('User DB Service', () => {
|
||||
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));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT'),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(result).toEqual(mockProfile);
|
||||
});
|
||||
|
||||
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' }, mockLogger)).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' }, 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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,19 +522,29 @@ describe('User DB Service', () => {
|
||||
it('should execute an UPDATE query for user preferences', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{}] });
|
||||
await userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("SET preferences = COALESCE(preferences, '{}'::jsonb) || $1"), [{ darkMode: true }, '123']);
|
||||
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 }, mockLogger)).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 }, 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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,13 +552,21 @@ describe('User DB Service', () => {
|
||||
it('should execute an UPDATE query for the user password', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await userRepo.updateUserPassword('123', 'newhash', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET password_hash = $1 WHERE user_id = $2', ['newhash', '123']);
|
||||
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', mockLogger)).rejects.toThrow('Failed to update user password in database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in updateUserPassword');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,13 +574,21 @@ describe('User DB Service', () => {
|
||||
it('should execute a DELETE query for the user', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await userRepo.deleteUserById('123', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.users WHERE user_id = $1', ['123']);
|
||||
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', mockLogger)).rejects.toThrow('Failed to delete user from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in deleteUserById');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,13 +596,21 @@ describe('User DB Service', () => {
|
||||
it('should execute an UPDATE query to save the refresh token', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
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']);
|
||||
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', mockLogger)).rejects.toThrow('Failed to save refresh token.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in saveRefreshToken');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -440,22 +618,34 @@ describe('User DB Service', () => {
|
||||
it('should query for a user by their refresh token', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }], rowCount: 1 });
|
||||
await userRepo.findUserByRefreshToken('a-token', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE refresh_token = $1'), ['a-token']);
|
||||
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: [], rowCount: 0 });
|
||||
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.');
|
||||
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.',
|
||||
);
|
||||
});
|
||||
|
||||
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.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow('Failed to find user by refresh token.');
|
||||
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(
|
||||
'Failed to find user by refresh token.',
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in findUserByRefreshToken');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in findUserByRefreshToken',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,7 +654,8 @@ describe('User DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await userRepo.deleteRefreshToken('a-token', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', ['a-token']
|
||||
'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1',
|
||||
['a-token'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -474,7 +665,10 @@ describe('User DB Service', () => {
|
||||
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');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Database error in deleteRefreshToken',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -483,23 +677,36 @@ describe('User DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
const expires = new Date();
|
||||
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]);
|
||||
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],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger)).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 () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
const expires = new Date();
|
||||
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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -507,13 +714,20 @@ describe('User DB Service', () => {
|
||||
it('should query for tokens where expires_at > NOW()', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await userRepo.getValidResetTokens(mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE expires_at > NOW()'));
|
||||
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(mockLogger)).rejects.toThrow('Failed to retrieve valid reset tokens.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in getValidResetTokens');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,13 +735,19 @@ describe('User DB Service', () => {
|
||||
it('should execute a DELETE query for the token hash', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await userRepo.deleteResetToken('token-hash', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', ['token-hash']);
|
||||
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');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), tokenHash: 'token-hash' },
|
||||
'Database error in deleteResetToken',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,16 +755,25 @@ describe('User DB Service', () => {
|
||||
it('should execute a DELETE query for expired tokens and return the count', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 5 });
|
||||
const result = await userRepo.deleteExpiredResetTokens(mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()');
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()',
|
||||
);
|
||||
expect(result).toBe(5);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('[DB deleteExpiredResetTokens] Deleted 5 expired password reset tokens.');
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[DB deleteExpiredResetTokens] Deleted 5 expired password reset tokens.',
|
||||
);
|
||||
});
|
||||
|
||||
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.deleteExpiredResetTokens(mockLogger)).rejects.toThrow('Failed to delete expired password reset tokens.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in deleteExpiredResetTokens');
|
||||
await expect(userRepo.deleteExpiredResetTokens(mockLogger)).rejects.toThrow(
|
||||
'Failed to delete expired password reset tokens.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in deleteExpiredResetTokens',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -562,7 +791,9 @@ describe('User DB Service', () => {
|
||||
const { PersonalizationRepository } = await import('./personalization.db');
|
||||
|
||||
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
||||
findProfileSpy.mockResolvedValue(createMockUserProfile({ user: { user_id: '123', email: '123@example.com' } }));
|
||||
findProfileSpy.mockResolvedValue(
|
||||
createMockUserProfile({ user: { user_id: '123', email: '123@example.com' } }),
|
||||
);
|
||||
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems');
|
||||
getWatchedItemsSpy.mockResolvedValue([]);
|
||||
const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists');
|
||||
@@ -583,19 +814,27 @@ describe('User DB Service', () => {
|
||||
// Arrange: Mock findUserProfileById to throw a NotFoundError, as per its contract (ADR-001).
|
||||
// The exportUserData function will catch this and re-throw a generic error.
|
||||
const { NotFoundError } = await import('./errors.db');
|
||||
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new NotFoundError('Profile not found'));
|
||||
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(
|
||||
new NotFoundError('Profile not found'),
|
||||
);
|
||||
|
||||
// Act & Assert: The outer function catches the NotFoundError and re-throws it.
|
||||
await expect(exportUserData('123', mockLogger)).rejects.toThrow('Failed to export user data.');
|
||||
await expect(exportUserData('123', mockLogger)).rejects.toThrow(
|
||||
'Failed to export user data.',
|
||||
);
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
// Arrange: Force a failure in one of the parallel calls
|
||||
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new Error('DB Error'));
|
||||
// Arrange: Force a failure in one of the parallel calls
|
||||
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(
|
||||
new Error('DB Error'),
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
await expect(exportUserData('123', mockLogger)).rejects.toThrow('Failed to export user data.');
|
||||
await expect(exportUserData('123', mockLogger)).rejects.toThrow(
|
||||
'Failed to export user data.',
|
||||
);
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -606,7 +845,7 @@ describe('User DB Service', () => {
|
||||
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']
|
||||
['follower-1', 'following-1'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -614,14 +853,21 @@ describe('User DB Service', () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(userRepo.followUser('follower-1', 'non-existent-user', mockLogger)).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', mockLogger)).rejects.toThrow('Failed to follow user.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, followerId: 'follower-1', followingId: 'following-1' }, 'Database error in followUser');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -631,15 +877,20 @@ describe('User DB Service', () => {
|
||||
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']
|
||||
['follower-1', 'following-1'],
|
||||
);
|
||||
});
|
||||
|
||||
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', mockLogger)).rejects.toThrow('Failed to unfollow user.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, followerId: 'follower-1', followingId: 'following-1' }, 'Database error in unfollowUser');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -662,7 +913,7 @@ describe('User DB Service', () => {
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.activity_log al'),
|
||||
['user-123', 10, 0]
|
||||
['user-123', 10, 0],
|
||||
);
|
||||
expect(result).toEqual(mockFeedItems);
|
||||
});
|
||||
@@ -676,8 +927,13 @@ 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.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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -700,12 +956,7 @@ describe('User DB Service', () => {
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[
|
||||
queryData.user_id,
|
||||
queryData.query_text,
|
||||
queryData.result_count,
|
||||
queryData.was_successful,
|
||||
]
|
||||
[queryData.user_id, queryData.query_text, queryData.result_count, queryData.was_successful],
|
||||
);
|
||||
expect(result).toEqual(mockLoggedQuery);
|
||||
});
|
||||
@@ -726,14 +977,24 @@ describe('User DB Service', () => {
|
||||
|
||||
await userRepo.logSearchQuery(queryData, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), [null, 'anonymous search', 10, true]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), [
|
||||
null,
|
||||
'anonymous search',
|
||||
10,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
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' }, mockLogger)).rejects.toThrow('Failed to log search query.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, queryData: { query_text: 'fail' } }, 'Database error in logSearchQuery');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,24 +20,29 @@ describe('Database Service Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Act: Call the createUser function
|
||||
const createdUser = await db.userRepo.createUser(email, passwordHash, { full_name: fullName }, logger);
|
||||
const createdUser = await db.userRepo.createUser(
|
||||
email,
|
||||
passwordHash,
|
||||
{ full_name: fullName },
|
||||
logger,
|
||||
);
|
||||
|
||||
// Assert: Check that the user was created with the correct details
|
||||
expect(createdUser).toBeDefined();
|
||||
expect(createdUser.user.email).toBe(email);
|
||||
expect(createdUser.user_id).toBeTypeOf('string');
|
||||
expect(createdUser.user.email).toBe(email); // This is correct
|
||||
expect(createdUser.user.user_id).toBeTypeOf('string');
|
||||
|
||||
// Act: Try to find the user we just created
|
||||
const foundUser = await db.userRepo.findUserByEmail(email, logger);
|
||||
|
||||
// Assert: Check that the found user matches the created user
|
||||
expect(foundUser).toBeDefined();
|
||||
expect(foundUser?.user_id).toBe(createdUser.user_id);
|
||||
expect(foundUser?.user_id).toBe(createdUser.user.user_id);
|
||||
expect(foundUser?.email).toBe(email);
|
||||
|
||||
// Also, verify the profile was created by the trigger
|
||||
const profile = await db.userRepo.findUserProfileById(createdUser.user_id, logger);
|
||||
const profile = await db.userRepo.findUserProfileById(createdUser.user.user_id, logger);
|
||||
expect(profile).toBeDefined();
|
||||
expect(profile?.full_name).toBe(fullName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,13 @@ describe('Shopping List DB Service Tests', () => {
|
||||
// Arrange: Create a fresh user for this specific test.
|
||||
const email = `list-user-${Date.now()}@example.com`;
|
||||
const passwordHash = await bcrypt.hash('password123', 10);
|
||||
const user = await db.userRepo.createUser(email, passwordHash, { full_name: 'List Test User' }, logger);
|
||||
const testUserId = user.user_id;
|
||||
const userprofile = await db.userRepo.createUser(
|
||||
email,
|
||||
passwordHash,
|
||||
{ full_name: 'List Test User' },
|
||||
logger,
|
||||
);
|
||||
const testUserId = userprofile.user.user_id;
|
||||
|
||||
onTestFinished(async () => {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
||||
@@ -38,8 +43,13 @@ describe('Shopping List DB Service Tests', () => {
|
||||
// Arrange: Create a fresh user for this specific test.
|
||||
const email = `privacy-user-${Date.now()}@example.com`;
|
||||
const passwordHash = await bcrypt.hash('password123', 10);
|
||||
const user = await db.userRepo.createUser(email, passwordHash, { full_name: 'Privacy Test User' }, logger);
|
||||
const testUserId = user.user_id;
|
||||
const userprofile = await db.userRepo.createUser(
|
||||
email,
|
||||
passwordHash,
|
||||
{ full_name: 'Privacy Test User' },
|
||||
logger,
|
||||
);
|
||||
const testUserId = userprofile.user.user_id;
|
||||
|
||||
onTestFinished(async () => {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
||||
@@ -56,4 +66,4 @@ describe('Shopping List DB Service Tests', () => {
|
||||
expect(lists).toBeDefined();
|
||||
expect(lists).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,12 @@ describe('System API Routes Integration Tests', () => {
|
||||
const response = await apiClient.checkPm2Status();
|
||||
const result = await response.json();
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('message');
|
||||
// If the response is successful (200 OK), it must have a 'success' property.
|
||||
// If it's an error (e.g., 500 because pm2 command not found), it will only have 'message'.
|
||||
if (response.ok) {
|
||||
expect(result).toHaveProperty('success');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,10 +17,16 @@ describe('User API Routes Integration Tests', () => {
|
||||
// --- START DEBUG LOGGING ---
|
||||
// Query the DB from within the test file to see its state.
|
||||
beforeAll(async () => {
|
||||
const res = await getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id');
|
||||
console.log('\n--- [user.integration.test.ts] Users found in DB from TEST perspective (beforeAll): ---');
|
||||
const res = await getPool().query(
|
||||
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
|
||||
);
|
||||
console.log(
|
||||
'\n--- [user.integration.test.ts] Users found in DB from TEST perspective (beforeAll): ---',
|
||||
);
|
||||
console.table(res.rows);
|
||||
console.log('-------------------------------------------------------------------------------------\n');
|
||||
console.log(
|
||||
'-------------------------------------------------------------------------------------\n',
|
||||
);
|
||||
});
|
||||
// --- END DEBUG LOGGING ---
|
||||
// Before any tests run, create a new user and log them in.
|
||||
@@ -38,10 +44,14 @@ describe('User API Routes Integration Tests', () => {
|
||||
const pool = getPool();
|
||||
try {
|
||||
// Find all users created during this test run by their email pattern.
|
||||
const res = await pool.query("SELECT user_id FROM public.users WHERE email LIKE 'user-test-%' OR email LIKE 'delete-me-%' OR email LIKE 'reset-me-%'");
|
||||
const res = await pool.query(
|
||||
"SELECT user_id FROM public.users WHERE email LIKE 'user-test-%' OR email LIKE 'delete-me-%' OR email LIKE 'reset-me-%'",
|
||||
);
|
||||
if (res.rows.length > 0) {
|
||||
const userIds = res.rows.map(r => r.user_id);
|
||||
logger.debug(`[user.integration.test.ts afterAll] Cleaning up ${userIds.length} test users...`);
|
||||
const userIds = res.rows.map((r) => r.user_id);
|
||||
logger.debug(
|
||||
`[user.integration.test.ts afterAll] Cleaning up ${userIds.length} test users...`,
|
||||
);
|
||||
// Use a direct DB query for cleanup, which is faster and more reliable than API calls.
|
||||
await pool.query('DELETE FROM public.users WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
}
|
||||
@@ -70,7 +80,9 @@ describe('User API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
// Act: Call the update endpoint with the new data and the auth token.
|
||||
const response = await apiClient.updateUserProfile(profileUpdates, { tokenOverride: authToken });
|
||||
const response = await apiClient.updateUserProfile(profileUpdates, {
|
||||
tokenOverride: authToken,
|
||||
});
|
||||
const updatedProfile = await response.json();
|
||||
|
||||
// Assert: Check that the returned profile reflects the changes.
|
||||
@@ -78,7 +90,9 @@ describe('User API Routes Integration Tests', () => {
|
||||
expect(updatedProfile.full_name).toBe('Updated Test User');
|
||||
|
||||
// Also, fetch the profile again to ensure the change was persisted.
|
||||
const refetchResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: authToken });
|
||||
const refetchResponse = await apiClient.getAuthenticatedUserProfile({
|
||||
tokenOverride: authToken,
|
||||
});
|
||||
const refetchedProfile = await refetchResponse.json();
|
||||
expect(refetchedProfile.full_name).toBe('Updated Test User');
|
||||
});
|
||||
@@ -90,7 +104,9 @@ describe('User API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
// Act: Call the update endpoint.
|
||||
const response = await apiClient.updateUserPreferences(preferenceUpdates, { tokenOverride: authToken });
|
||||
const response = await apiClient.updateUserPreferences(preferenceUpdates, {
|
||||
tokenOverride: authToken,
|
||||
});
|
||||
const updatedProfile = await response.json();
|
||||
|
||||
// Assert: Check that the preferences object in the returned profile is updated.
|
||||
@@ -108,8 +124,11 @@ describe('User API Routes Integration Tests', () => {
|
||||
// with an error message indicating the password is too weak.
|
||||
const response = await apiClient.registerUser(email, weakPassword, 'Weak Password User');
|
||||
expect(response.ok).toBe(false);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.message).toMatch(/Password is too weak/);
|
||||
const errorData = (await response.json()) as { message: string; errors: { message: string }[] };
|
||||
// For validation errors, the detailed messages are in the `errors` array.
|
||||
// We join them to check for the specific feedback from the password strength checker.
|
||||
const detailedErrorMessage = errorData.errors?.map((e) => e.message).join(' ');
|
||||
expect(detailedErrorMessage).toMatch(/Password is too weak/);
|
||||
});
|
||||
|
||||
it('should allow a user to delete their own account and then fail to log in', async () => {
|
||||
@@ -118,7 +137,9 @@ describe('User API Routes Integration Tests', () => {
|
||||
const { token: deletionToken } = await createAndLoginUser({ email: deletionEmail });
|
||||
|
||||
// Act: Call the delete endpoint with the correct password and token.
|
||||
const response = await apiClient.deleteUserAccount(TEST_PASSWORD, { tokenOverride: deletionToken });
|
||||
const response = await apiClient.deleteUserAccount(TEST_PASSWORD, {
|
||||
tokenOverride: deletionToken,
|
||||
});
|
||||
const deleteResponse = await response.json();
|
||||
|
||||
// Assert: Check for a successful deletion message.
|
||||
@@ -139,8 +160,8 @@ describe('User API Routes Integration Tests', () => {
|
||||
// Act 1: Request a password reset. In our test environment, the token is returned in the response.
|
||||
const resetRequestRawResponse = await apiClient.requestPasswordReset(resetEmail);
|
||||
if (!resetRequestRawResponse.ok) {
|
||||
const errorData = await resetRequestRawResponse.json();
|
||||
throw new Error(errorData.message || 'Password reset request failed');
|
||||
const errorData = await resetRequestRawResponse.json();
|
||||
throw new Error(errorData.message || 'Password reset request failed');
|
||||
}
|
||||
const resetRequestResponse = await resetRequestRawResponse.json();
|
||||
const resetToken = resetRequestResponse.token;
|
||||
@@ -153,8 +174,8 @@ describe('User API Routes Integration Tests', () => {
|
||||
const newPassword = 'my-new-secure-password-!@#$';
|
||||
const resetRawResponse = await apiClient.resetPassword(resetToken!, newPassword);
|
||||
if (!resetRawResponse.ok) {
|
||||
const errorData = await resetRawResponse.json();
|
||||
throw new Error(errorData.message || 'Password reset failed');
|
||||
const errorData = await resetRawResponse.json();
|
||||
throw new Error(errorData.message || 'Password reset failed');
|
||||
}
|
||||
const resetResponse = await resetRawResponse.json();
|
||||
|
||||
@@ -171,7 +192,11 @@ describe('User API Routes Integration Tests', () => {
|
||||
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
||||
it('should allow a user to add and remove a watched item', async () => {
|
||||
// Act 1: Add a new watched item. The API returns the created master item.
|
||||
const addResponse = await apiClient.addWatchedItem('Integration Test Item', 'Other/Miscellaneous', authToken);
|
||||
const addResponse = await apiClient.addWatchedItem(
|
||||
'Integration Test Item',
|
||||
'Other/Miscellaneous',
|
||||
authToken,
|
||||
);
|
||||
const newItem = await addResponse.json();
|
||||
|
||||
// Assert 1: Check that the item was created correctly.
|
||||
@@ -183,7 +208,12 @@ describe('User API Routes Integration Tests', () => {
|
||||
const watchedItems = await watchedItemsResponse.json();
|
||||
|
||||
// Assert 2: Verify the new item is in the user's watched list.
|
||||
expect(watchedItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(true);
|
||||
expect(
|
||||
watchedItems.some(
|
||||
(item: MasterGroceryItem) =>
|
||||
item.master_grocery_item_id === newItem.master_grocery_item_id,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Act 3: Remove the watched item.
|
||||
await apiClient.removeWatchedItem(newItem.master_grocery_item_id, authToken);
|
||||
@@ -191,12 +221,20 @@ describe('User API Routes Integration Tests', () => {
|
||||
// Assert 3: Fetch again and verify the item is gone.
|
||||
const finalWatchedItemsResponse = await apiClient.fetchWatchedItems(authToken);
|
||||
const finalWatchedItems = await finalWatchedItemsResponse.json();
|
||||
expect(finalWatchedItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(false);
|
||||
expect(
|
||||
finalWatchedItems.some(
|
||||
(item: MasterGroceryItem) =>
|
||||
item.master_grocery_item_id === newItem.master_grocery_item_id,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow a user to manage a shopping list', async () => {
|
||||
// Act 1: Create a new shopping list.
|
||||
const createListResponse = await apiClient.createShoppingList('My Integration Test List', authToken);
|
||||
const createListResponse = await apiClient.createShoppingList(
|
||||
'My Integration Test List',
|
||||
authToken,
|
||||
);
|
||||
const newList = await createListResponse.json();
|
||||
|
||||
// Assert 1: Check that the list was created.
|
||||
@@ -204,7 +242,11 @@ describe('User API Routes Integration Tests', () => {
|
||||
expect(newList.name).toBe('My Integration Test List');
|
||||
|
||||
// Act 2: Add an item to the new list.
|
||||
const addItemResponse = await apiClient.addShoppingListItem(newList.shopping_list_id, { customItemName: 'Custom Test Item' }, authToken);
|
||||
const addItemResponse = await apiClient.addShoppingListItem(
|
||||
newList.shopping_list_id,
|
||||
{ customItemName: 'Custom Test Item' },
|
||||
authToken,
|
||||
);
|
||||
const addedItem = await addItemResponse.json();
|
||||
|
||||
// Assert 2: Check that the item was added.
|
||||
@@ -214,10 +256,14 @@ describe('User API Routes Integration Tests', () => {
|
||||
// Assert 3: Fetch all lists and verify the new item is present in the correct list.
|
||||
const fetchResponse = await apiClient.fetchShoppingLists(authToken);
|
||||
const lists = await fetchResponse.json();
|
||||
const updatedList = lists.find((l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id);
|
||||
const updatedList = lists.find(
|
||||
(l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id,
|
||||
);
|
||||
// The `add` endpoint returns the new ID as a string, while the `fetch` endpoint returns it as a number.
|
||||
// To make the test robust, we convert both to strings before comparing.
|
||||
expect(String(updatedList?.items[0].shopping_list_item_id)).toEqual(String(addedItem.shopping_list_item_id));
|
||||
expect(String(updatedList?.items[0].shopping_list_item_id)).toEqual(
|
||||
String(addedItem.shopping_list_item_id),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user