Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621d30b84f | ||
| ed857f588a |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
1267
src/App.test.tsx
1267
src/App.test.tsx
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user