Compare commits

...

5 Commits

Author SHA1 Message Date
Gitea Actions
621d30b84f ci: Bump version to 0.0.3 [skip ci] 2025-12-22 21:54:39 +05:00
ed857f588a more fixin tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 08:47:18 -08:00
Gitea Actions
fee55b0afd ci: Bump version to 0.0.2 [skip ci] 2025-12-22 12:46:13 +05:00
35538ea011 Merge branches 'main' and 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h34m43s
2025-12-21 23:39:50 -08:00
368b8e704c final ts cleanup? 2025-12-21 23:39:13 -08:00
15 changed files with 2258 additions and 1341 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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\"",

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
};

View File

@@ -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();
});
});
});
});

View File

@@ -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>
);
};
};

View File

@@ -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);
});
});
});
});

View File

@@ -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;

View File

@@ -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.',
);
});
});
});
});

View File

@@ -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,
);
);

View File

@@ -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',
);
});
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
}
});
});
});
});

View File

@@ -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),
);
});
});
});
});