Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
621d30b84f ci: Bump version to 0.0.3 [skip ci] 2025-12-22 21:54:39 +05:00
ed857f588a more fixin tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 08:47:18 -08:00
7 changed files with 1584 additions and 903 deletions

4
package-lock.json generated
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
// src/hooks/useProfileAddress.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock, afterEach } from 'vitest';
import toast from 'react-hot-toast';
import { useProfileAddress } from './useProfileAddress';
import { useApi } from './useApi';
@@ -57,18 +57,39 @@ describe('useProfileAddress Hook', () => {
mockGeocode = vi.fn();
mockFetchAddress = vi.fn();
// Setup the mock for useApi to handle multiple renders and hook calls.
// The hook calls useApi twice per render in a stable order:
// 1. geocodeWrapper (via geocode)
// 2. fetchAddressWrapper (via fetchAddress)
let callCount = 0;
mockedUseApi.mockImplementation(() => {
callCount++;
if (callCount % 2 !== 0) {
return { execute: mockGeocode, loading: false, error: null, data: null, reset: vi.fn(), isRefetching: false };
} else {
return { execute: mockFetchAddress, loading: false, error: null, data: null, reset: vi.fn(), isRefetching: false };
// FIXED: Use function name checking for stability instead of call count.
// This prevents mocks from swapping if render order changes.
mockedUseApi.mockImplementation((fn: any) => {
const name = fn?.name;
if (name === 'geocodeWrapper') {
return {
execute: mockGeocode,
loading: false,
error: null,
data: null,
reset: vi.fn(),
isRefetching: false,
};
}
if (name === 'fetchAddressWrapper') {
return {
execute: mockFetchAddress,
loading: false,
error: null,
data: null,
reset: vi.fn(),
isRefetching: false,
};
}
// Default fallback
return {
execute: vi.fn(),
loading: false,
error: null,
data: null,
reset: vi.fn(),
isRefetching: false,
};
});
});
@@ -112,7 +133,7 @@ describe('useProfileAddress Hook', () => {
mockFetchAddress.mockResolvedValue(mockAddress);
const { result, rerender } = renderHook(
({ userProfile, isOpen }) => useProfileAddress(userProfile, isOpen),
{ initialProps: { userProfile: mockUserProfile, isOpen: true } }
{ initialProps: { userProfile: mockUserProfile, isOpen: true } },
);
await waitFor(() => {
@@ -126,15 +147,15 @@ describe('useProfileAddress Hook', () => {
});
it('should handle fetch failure gracefully', async () => {
mockFetchAddress.mockResolvedValue(null); // useApi returns null on failure
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
mockFetchAddress.mockResolvedValue(null);
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
await waitFor(() => {
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
});
await waitFor(() => {
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
});
expect(result.current.address).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(`[useProfileAddress] Fetch returned null for addressId: ${mockUserProfile.address_id}.`);
expect(result.current.address).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null'));
});
});
@@ -158,7 +179,7 @@ describe('useProfileAddress Hook', () => {
describe('handleManualGeocode', () => {
it('should call geocode API with the correct address string', async () => {
const { result } = renderHook(() => useProfileAddress(null, false));
act(() => {
result.current.handleAddressChange('address_line_1', '1 Infinite Loop');
result.current.handleAddressChange('city', 'Cupertino');
@@ -169,36 +190,38 @@ describe('useProfileAddress Hook', () => {
await result.current.handleManualGeocode();
});
expect(mockGeocode).toHaveBeenCalledWith('1 Infinite Loop, Cupertino, CA');
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('1 Infinite Loop'));
});
it('should update address with new coordinates on successful geocode', async () => {
const newCoords = { lat: 37.33, lng: -122.03 };
mockGeocode.mockResolvedValue(newCoords);
const { result } = renderHook(() => useProfileAddress(null, false));
const newCoords = { lat: 37.33, lng: -122.03 };
mockGeocode.mockResolvedValue(newCoords);
const { result } = renderHook(() => useProfileAddress(null, false));
act(() => {
result.current.handleAddressChange('city', 'Cupertino');
});
act(() => {
result.current.handleAddressChange('city', 'Cupertino');
});
await act(async () => {
await result.current.handleManualGeocode();
});
await act(async () => {
await result.current.handleManualGeocode();
});
expect(result.current.address.latitude).toBe(newCoords.lat);
expect(result.current.address.longitude).toBe(newCoords.lng);
expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
expect(result.current.address.latitude).toBe(newCoords.lat);
expect(result.current.address.longitude).toBe(newCoords.lng);
expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
});
it('should show an error toast if address string is empty', async () => {
const { result } = renderHook(() => useProfileAddress(null, false));
const { result } = renderHook(() => useProfileAddress(null, false));
await act(async () => {
await result.current.handleManualGeocode();
});
await act(async () => {
await result.current.handleManualGeocode();
});
expect(mockGeocode).not.toHaveBeenCalled();
expect(mockedToast.error).toHaveBeenCalledWith('Please fill in the address fields before geocoding.');
expect(mockGeocode).not.toHaveBeenCalled();
expect(mockedToast.error).toHaveBeenCalledWith(
'Please fill in the address fields before geocoding.',
);
});
});
@@ -212,68 +235,67 @@ describe('useProfileAddress Hook', () => {
});
it('should trigger geocode after user stops typing in an address without coordinates', async () => {
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
const newCoords = { lat: 38.89, lng: -77.03 };
mockGeocode.mockResolvedValue(newCoords);
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
const newCoords = { lat: 38.89, lng: -77.03 };
mockGeocode.mockResolvedValue(newCoords);
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
// Wait for initial fetch
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
// Wait for initial fetch
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
// Change the address
act(() => {
result.current.handleAddressChange('city', 'Washington');
});
// Change the address
act(() => {
result.current.handleAddressChange('city', 'Washington');
});
// Geocode should not be called immediately
expect(mockGeocode).not.toHaveBeenCalled();
// Geocode should not be called immediately due to debounce
expect(mockGeocode).not.toHaveBeenCalled();
// Wait for debounce period
act(() => {
vi.advanceTimersByTime(1600);
});
// Advance debounce timer
await act(async () => {
vi.advanceTimersByTime(1600);
});
await waitFor(() => {
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington'));
expect(result.current.address.latitude).toBe(newCoords.lat);
expect(result.current.address.longitude).toBe(newCoords.lng);
expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!');
});
await waitFor(() => {
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington'));
expect(result.current.address.latitude).toBe(newCoords.lat);
expect(result.current.address.longitude).toBe(newCoords.lng);
expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!');
});
});
it('should NOT trigger geocode if address already has coordinates', async () => {
mockFetchAddress.mockResolvedValue(mockAddress); // Has coords
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
mockFetchAddress.mockResolvedValue(mockAddress); // Has coords
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
act(() => {
result.current.handleAddressChange('city', 'NewCity');
});
act(() => {
result.current.handleAddressChange('city', 'NewCity');
});
act(() => {
vi.advanceTimersByTime(1600);
});
await act(async () => {
vi.advanceTimersByTime(1600);
});
expect(mockGeocode).not.toHaveBeenCalled();
expect(mockGeocode).not.toHaveBeenCalled();
});
it('should NOT trigger geocode on initial load, even if address has no coords', async () => {
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
// Wait to see if debounce triggers
act(() => {
vi.advanceTimersByTime(1600);
});
await act(async () => {
vi.advanceTimersByTime(1600);
});
// It shouldn't because the address hasn't been changed by the user yet
expect(mockGeocode).not.toHaveBeenCalled();
// Should not call because address hasn't changed from initial
expect(mockGeocode).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -4,7 +4,10 @@ import * as bcrypt from 'bcrypt';
import { Request, Response, NextFunction } from 'express';
// Define a type for the JWT verify callback function for type safety.
type VerifyCallback = (payload: { user_id: string }, done: (error: Error | null, user?: object | false) => void) => Promise<void>;
type VerifyCallback = (
payload: { user_id: string },
done: (error: Error | null, user?: object | false) => void,
) => Promise<void>;
// FIX: Use vi.hoisted to declare variables that need to be accessed inside vi.mock
const { verifyCallbackWrapper } = vi.hoisted(() => {
@@ -12,15 +15,15 @@ const { verifyCallbackWrapper } = vi.hoisted(() => {
// We use a wrapper object to hold the callback reference
// Initialize with a more specific type instead of `any`.
verifyCallbackWrapper: {
callback: null as VerifyCallback | null
}
callback: null as VerifyCallback | null,
},
};
});
// Mock the 'passport-jwt' module to capture the verify callback.
vi.mock('passport-jwt', () => ({
// The Strategy constructor is mocked to capture its second argument
Strategy: vi.fn(function(options, verify) {
Strategy: vi.fn(function (options, verify) {
// FIX: Assign to the hoisted wrapper object
verifyCallbackWrapper.callback = verify;
return { name: 'jwt', authenticate: vi.fn() };
@@ -30,21 +33,29 @@ vi.mock('passport-jwt', () => ({
// FIX: Add a similar mock for 'passport-local' to capture its verify callback.
const { localStrategyCallbackWrapper } = vi.hoisted(() => {
type LocalVerifyCallback = (req: Request, email: string, pass: string, done: (error: Error | null, user?: object | false, options?: { message: string }) => void) => Promise<void>;
type LocalVerifyCallback = (
req: Request,
email: string,
pass: string,
done: (error: Error | null, user?: object | false, options?: { message: string }) => void,
) => Promise<void>;
return {
localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null }
localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null },
};
});
vi.mock('passport-local', () => ({
Strategy: vi.fn(function(options, verify) {
Strategy: vi.fn(function (options, verify) {
localStrategyCallbackWrapper.callback = verify;
}),
}));
import * as db from '../services/db/index.db';
import { UserProfile } from '../types';
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockUserWithPasswordHash,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock dependencies before importing the passport configuration
@@ -118,7 +129,9 @@ describe('Passport Configuration', () => {
}),
refresh_token: 'mock-refresh-token',
};
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockAuthableProfile);
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(
mockAuthableProfile,
);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
// Act
@@ -127,11 +140,24 @@ describe('Passport Configuration', () => {
}
// Assert
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith('test@test.com', logger);
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith(
'test@test.com',
logger,
);
expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password');
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith(mockAuthableProfile.user.user_id, '127.0.0.1', logger);
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith(
mockAuthableProfile.user.user_id,
'127.0.0.1',
logger,
);
// The strategy now just strips auth fields.
const { password_hash, failed_login_attempts, last_failed_login, last_login_ip, refresh_token, ...expectedUserProfile } = mockAuthableProfile;
const {
password_hash,
failed_login_attempts,
last_failed_login,
refresh_token,
...expectedUserProfile
} = mockAuthableProfile;
expect(done).toHaveBeenCalledWith(null, expectedUserProfile);
});
@@ -165,14 +191,25 @@ describe('Passport Configuration', () => {
vi.mocked(mockedDb.adminRepo.incrementFailedLoginAttempts).mockResolvedValue(2);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
await localStrategyCallbackWrapper.callback(
mockReq,
'test@test.com',
'wrong_password',
done,
);
}
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger);
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({
action: 'login_failed_password',
details: { source_ip: '127.0.0.1', new_attempt_count: 2 },
}), logger);
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(
mockUser.user.user_id,
logger,
);
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: 'login_failed_password',
details: { source_ip: '127.0.0.1', new_attempt_count: 2 },
}),
logger,
);
expect(done).toHaveBeenCalledWith(null, false, { message: 'Incorrect email or password.' });
});
@@ -196,12 +233,22 @@ describe('Passport Configuration', () => {
vi.mocked(mockedDb.adminRepo.incrementFailedLoginAttempts).mockResolvedValue(5);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
await localStrategyCallbackWrapper.callback(
mockReq,
'test@test.com',
'wrong_password',
done,
);
}
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger);
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(
mockUser.user.user_id,
logger,
);
// It should now return the lockout message, not the generic "incorrect password"
expect(done).toHaveBeenCalledWith(null, false, { message: expect.stringContaining('Account is temporarily locked') });
expect(done).toHaveBeenCalledWith(null, false, {
message: expect.stringContaining('Account is temporarily locked'),
});
});
it('should call done(null, false) for an OAuth user (no password hash)', async () => {
@@ -221,10 +268,18 @@ describe('Passport Configuration', () => {
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'oauth@test.com', 'any_password', done);
await localStrategyCallbackWrapper.callback(
mockReq,
'oauth@test.com',
'any_password',
done,
);
}
expect(done).toHaveBeenCalledWith(null, false, { message: 'This account was created using a social login. Please use Google or GitHub to sign in.' });
expect(done).toHaveBeenCalledWith(null, false, {
message:
'This account was created using a social login. Please use Google or GitHub to sign in.',
});
});
it('should call done(null, false) if account is locked', async () => {
@@ -245,10 +300,17 @@ describe('Passport Configuration', () => {
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'locked@test.com', 'any_password', done);
await localStrategyCallbackWrapper.callback(
mockReq,
'locked@test.com',
'any_password',
done,
);
}
expect(done).toHaveBeenCalledWith(null, false, { message: 'Account is temporarily locked. Please try again in 15 minutes.' });
expect(done).toHaveBeenCalledWith(null, false, {
message: 'Account is temporarily locked. Please try again in 15 minutes.',
});
});
it('should allow login if lockout period has expired', async () => {
@@ -270,7 +332,12 @@ describe('Passport Configuration', () => {
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Correct password
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'expired@test.com', 'correct_password', done);
await localStrategyCallbackWrapper.callback(
mockReq,
'expired@test.com',
'correct_password',
done,
);
}
// Should proceed to successful login
@@ -293,8 +360,12 @@ describe('Passport Configuration', () => {
describe('JwtStrategy (Isolated Callback Logic)', () => {
it('should call done(null, userProfile) on successful authentication', async () => {
// Arrange
const jwtPayload = { user_id: 'user-123' };
const mockProfile = { role: 'user', points: 100, user: { user_id: 'user-123', email: 'test@test.com' } } as UserProfile;
const jwtPayload = { user_id: 'user-123' };
const mockProfile = {
role: 'user',
points: 100,
user: { user_id: 'user-123', email: 'test@test.com' },
} as UserProfile;
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockProfile);
const done = vi.fn();
@@ -360,7 +431,10 @@ describe('Passport Configuration', () => {
it('should call next() if user has "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }),
user: createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-id', email: 'admin@test.com' },
}),
};
// Act
@@ -374,7 +448,10 @@ describe('Passport Configuration', () => {
it('should return 403 Forbidden if user does not have "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: createMockUserProfile({ role: 'user', user: { user_id: 'user-id', email: 'user@test.com' } }),
user: createMockUserProfile({
role: 'user',
user: { user_id: 'user-id', email: 'user@test.com' },
}),
};
// Act
@@ -383,7 +460,9 @@ describe('Passport Configuration', () => {
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Forbidden: Administrator access required.',
});
});
it('should return 403 Forbidden if req.user is missing', () => {
@@ -413,7 +492,9 @@ describe('Passport Configuration', () => {
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Forbidden: Administrator access required.',
});
});
});
@@ -428,10 +509,13 @@ describe('Passport Configuration', () => {
it('should populate req.user and call next() if authentication succeeds', () => {
// Arrange
const mockReq = {} as Request;
const mockUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } });
const mockUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-id', email: 'admin@test.com' },
});
// Mock passport.authenticate to call its callback with a user
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined)
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined),
);
// Act
@@ -446,7 +530,7 @@ describe('Passport Configuration', () => {
// Arrange
const mockReq = {} as Request;
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, undefined)
(_strategy, _options, callback) => () => callback?.(null, false, undefined),
);
optionalAuth(mockReq, mockRes as Response, mockNext);
@@ -461,7 +545,7 @@ describe('Passport Configuration', () => {
const mockInfo = { message: 'Token expired' };
// Mock passport.authenticate to call its callback with an info object
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo)
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo),
);
// Act
@@ -479,7 +563,7 @@ describe('Passport Configuration', () => {
const mockInfoError = new Error('Token is malformed');
// Mock passport.authenticate to call its callback with an info object
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, mockInfoError)
(_strategy, _options, callback) => () => callback?.(null, false, mockInfoError),
);
// Act
@@ -487,7 +571,10 @@ describe('Passport Configuration', () => {
// Assert
// info.message is 'Token is malformed'
expect(logger.info).toHaveBeenCalledWith({ info: 'Token is malformed' }, 'Optional auth info:');
expect(logger.info).toHaveBeenCalledWith(
{ info: 'Token is malformed' },
'Optional auth info:',
);
expect(mockNext).toHaveBeenCalledTimes(1);
});
@@ -497,14 +584,17 @@ describe('Passport Configuration', () => {
const mockInfo = { custom: 'some info' };
// Mock passport.authenticate to call its callback with a custom info object
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any)
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any),
);
// Act
optionalAuth(mockReq, mockRes as Response, mockNext);
// Assert
expect(logger.info).toHaveBeenCalledWith({ info: mockInfo.toString() }, 'Optional auth info:');
expect(logger.info).toHaveBeenCalledWith(
{ info: mockInfo.toString() },
'Optional auth info:',
);
expect(mockNext).toHaveBeenCalledTimes(1);
});
@@ -514,7 +604,7 @@ describe('Passport Configuration', () => {
const authError = new Error('Malformed token');
// Mock passport.authenticate to call its callback with an error
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(authError, false, undefined)
(_strategy, _options, callback) => () => callback?.(authError, false, undefined),
);
// Act
@@ -567,4 +657,4 @@ describe('Passport Configuration', () => {
expect(mockNext).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -18,26 +18,30 @@ import { logger as mockLoggerInstance } from './logger.server';
// Explicitly unmock the service under test to ensure we import the real implementation.
vi.unmock('./aiService.server');
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => {
const mockGenerateContent = vi.fn();
const mockToBuffer = vi.fn();
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp };
});
// Mock sharp, as it's a direct dependency of the service.
const mockToBuffer = vi.fn();
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
vi.mock('sharp', () => ({
__esModule: true,
default: mockSharp,
}));
// Mock @google/genai
const mockGenerateContent = vi.fn();
vi.mock('@google/genai', () => {
return {
GoogleGenAI: vi.fn(function() {
GoogleGenAI: vi.fn(function () {
return {
models: {
generateContent: mockGenerateContent
}
generateContent: mockGenerateContent,
},
};
})
}),
};
});
@@ -55,9 +59,9 @@ describe('AI Service (Server)', () => {
vi.clearAllMocks();
// Reset modules to ensure the service re-initializes with the mocks
mockAiClient.generateContent.mockResolvedValue({
text: '[]',
candidates: []
mockAiClient.generateContent.mockResolvedValue({
text: '[]',
candidates: [],
});
});
@@ -82,12 +86,16 @@ describe('AI Service (Server)', () => {
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
console.log("TEST START: 'should throw an error if GEMINI_API_KEY is not set...'");
console.log(`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`);
console.log(
`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
);
// Simulate a non-test environment
process.env.NODE_ENV = 'production';
delete process.env.GEMINI_API_KEY;
delete process.env.VITEST_POOL_ID;
console.log(`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`);
console.log(
`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
);
let error: Error | undefined;
// Dynamically import the class to re-evaluate the constructor logic
@@ -100,7 +108,9 @@ describe('AI Service (Server)', () => {
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe('GEMINI_API_KEY environment variable not set for server-side AI calls.');
expect(error?.message).toBe(
'GEMINI_API_KEY environment variable not set for server-side AI calls.',
);
});
it('should use a mock placeholder if API key is missing in a test environment', async () => {
@@ -113,40 +123,46 @@ describe('AI Service (Server)', () => {
const service = new AIService(mockLoggerInstance);
// Assert: Check that the warning was logged and the mock client is in use
expect(mockLoggerInstance.warn).toHaveBeenCalledWith('[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.');
await expect((service as any).aiClient.generateContent({ contents: [] })).resolves.toBeDefined();
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
'[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.',
);
await expect(
(service as any).aiClient.generateContent({ contents: [] }),
).resolves.toBeDefined();
});
it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => {
process.env.GEMINI_API_KEY = 'test-key';
// We need to force the constructor to use the real client logic, not the injected mock.
// So we instantiate AIService without passing aiClient.
// Reset modules to pick up the mock for @google/genai
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
// Access the private aiClient (which is now the adapter)
const adapter = (service as any).aiClient;
const request = { contents: [{ parts: [{ text: 'test' }] }] };
await adapter.generateContent(request);
expect(mockGenerateContent).toHaveBeenCalledWith({
model: 'gemini-2.5-flash',
...request
...request,
});
});
it('should throw error if adapter is called without content', async () => {
process.env.GEMINI_API_KEY = 'test-key';
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
const adapter = (service as any).aiClient;
await expect(adapter.generateContent({})).rejects.toThrow('AIService.generateContent requires at least one content element.');
process.env.GEMINI_API_KEY = 'test-key';
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
const adapter = (service as any).aiClient;
await expect(adapter.generateContent({})).rejects.toThrow(
'AIService.generateContent requires at least one content element.',
);
});
});
@@ -156,11 +172,15 @@ describe('AI Service (Server)', () => {
{ "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 },
{ "raw_item_description": "AVOCADO", "price_paid_cents": 299 }
]`;
mockAiClient.generateContent.mockResolvedValue({ text: mockAiResponseText, candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance);
const result = await aiServiceInstance.extractItemsFromReceiptImage(
'path/to/image.jpg',
'image/jpeg',
mockLoggerInstance,
);
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result).toEqual([
@@ -173,9 +193,13 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockResolvedValue({ text: 'This is not JSON.', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance)).rejects.toThrow(
'AI response did not contain a valid JSON array.'
);
await expect(
aiServiceInstance.extractItemsFromReceiptImage(
'path/to/image.jpg',
'image/jpeg',
mockLoggerInstance,
),
).rejects.toThrow('AI response did not contain a valid JSON array.');
});
it('should throw an error if the AI API call fails', async () => {
@@ -183,16 +207,24 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockRejectedValue(apiError);
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance))
.rejects.toThrow(apiError);
await expect(
aiServiceInstance.extractItemsFromReceiptImage(
'path/to/image.jpg',
'image/jpeg',
mockLoggerInstance,
),
).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "[extractItemsFromReceiptImage] An error occurred during the process."
{ err: apiError },
'[extractItemsFromReceiptImage] An error occurred during the process.',
);
});
});
describe('extractCoreDataFromFlyerImage', () => {
const mockMasterItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
const mockMasterItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
];
it('should extract and post-process flyer data correctly', async () => {
const mockAiResponse = {
@@ -200,14 +232,37 @@ describe('AI Service (Server)', () => {
valid_from: '2024-01-01',
valid_to: '2024-01-07',
items: [
{ item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', category_name: 'Produce', master_item_id: 1 },
{ item: 'Oranges', price_display: null, price_in_cents: null, quantity: undefined, category_name: null, master_item_id: null },
{
item: 'Apples',
price_display: '$1.99',
price_in_cents: 199,
quantity: '1lb',
category_name: 'Produce',
master_item_id: 1,
},
{
item: 'Oranges',
price_display: null,
price_in_cents: null,
quantity: undefined,
category_name: null,
master_item_id: null,
},
],
};
mockAiClient.generateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse), candidates: [] });
mockAiClient.generateContent.mockResolvedValue({
text: JSON.stringify(mockAiResponse),
candidates: [],
});
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await aiServiceInstance.extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems, undefined, undefined, mockLoggerInstance);
const result = await aiServiceInstance.extractCoreDataFromFlyerImage(
[{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }],
mockMasterItems,
undefined,
undefined,
mockLoggerInstance,
);
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result.store_name).toBe('Test Store');
@@ -221,20 +276,36 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockResolvedValue({ text: 'not a json object', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(
'AI response did not contain a valid JSON object.'
);
await expect(
aiServiceInstance.extractCoreDataFromFlyerImage(
[],
mockMasterItems,
undefined,
undefined,
mockLoggerInstance,
),
).rejects.toThrow('AI response did not contain a valid JSON object.');
});
it('should throw an error if the AI response contains malformed JSON', async () => {
console.log("TEST START: 'should throw an error if the AI response contains malformed JSON'");
// Arrange: AI returns a string that looks like JSON but is invalid
mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] });
mockAiClient.generateContent.mockResolvedValue({
text: '{ "store_name": "Incomplete, }',
candidates: [],
});
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance))
.rejects.toThrow('AI response did not contain a valid JSON object.');
await expect(
aiServiceInstance.extractCoreDataFromFlyerImage(
[],
mockMasterItems,
undefined,
undefined,
mockLoggerInstance,
),
).rejects.toThrow('AI response did not contain a valid JSON object.');
});
it('should throw an error if the AI API call fails', async () => {
@@ -244,48 +315,103 @@ describe('AI Service (Server)', () => {
mockAiClient.generateContent.mockRejectedValue(apiError);
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith({ err: apiError },
"[extractCoreDataFromFlyerImage] The entire process failed."
// Act & Assert
await expect(
aiServiceInstance.extractCoreDataFromFlyerImage(
[],
mockMasterItems,
undefined,
undefined,
mockLoggerInstance,
),
).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError },
'[extractCoreDataFromFlyerImage] The entire process failed.',
);
});
});
describe('_buildFlyerExtractionPrompt (private method)', () => {
it('should include a strong hint for userProfileAddress', () => {
const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: undefined, userProfileAddress: string) => string })._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown');
expect(prompt).toContain('The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.');
const prompt = (
aiServiceInstance as unknown as {
_buildFlyerExtractionPrompt: (
masterItems: [],
submitterIp: undefined,
userProfileAddress: string,
) => string;
}
)._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown');
expect(prompt).toContain(
'The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.',
);
});
it('should include a general hint for submitterIp when no address is present', () => {
const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string })._buildFlyerExtractionPrompt([], '123.45.67.89');
expect(prompt).toContain('The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store\'s region.');
const prompt = (
aiServiceInstance as unknown as {
_buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string;
}
)._buildFlyerExtractionPrompt([], '123.45.67.89');
expect(prompt).toContain(
"The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.",
);
});
it('should not include any location hint if no IP or address is provided', () => {
const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string })._buildFlyerExtractionPrompt([]);
const prompt = (
aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string }
)._buildFlyerExtractionPrompt([]);
expect(prompt).not.toContain('Use this as a strong hint');
expect(prompt).not.toContain('Use this as a general hint');
});
});
describe('_parseJsonFromAiResponse (private method)', () => {
it('should return null for undefined or empty input', () => { // This was a duplicate, fixed.
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(undefined, mockLoggerInstance)).toBeNull();
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse('', mockLoggerInstance)).toBeNull();
it('should return null for undefined or empty input', () => {
// This was a duplicate, fixed.
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null;
}
)._parseJsonFromAiResponse(undefined, mockLoggerInstance),
).toBeNull();
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
}
)._parseJsonFromAiResponse('', mockLoggerInstance),
).toBeNull();
});
it('should correctly parse a clean JSON string', () => {
const json = '{ "key": "value" }';
// Use a type-safe assertion to access the private method for testing.
const result = (aiServiceInstance as unknown as { _parseJsonFromAiResponse: <T>(text: string, logger: Logger) => T | null })._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance);
const result = (
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: <T>(text: string, logger: Logger) => T | null;
}
)._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance);
expect(result).toEqual({ key: 'value' });
});
it('should extract and parse JSON wrapped in markdown and other text', () => { // This was a duplicate, fixed.
const responseText = 'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => { data: boolean } })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual({ data: true });
it('should extract and parse JSON wrapped in markdown and other text', () => {
// This was a duplicate, fixed.
const responseText =
'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (
text: string,
logger: typeof mockLoggerInstance,
) => { data: boolean };
}
)._parseJsonFromAiResponse(responseText, mockLoggerInstance),
).toEqual({ data: true });
});
it('should handle JSON arrays correctly', () => {
@@ -295,7 +421,10 @@ describe('AI Service (Server)', () => {
// --- FULL DIAGNOSTIC LOGGING REMAINS FOR PROOF ---
console.log('\n--- TEST LOG: "should handle JSON arrays correctly" ---');
console.log(' - Test Input String:', JSON.stringify(responseText));
const result = (aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance);
const result = (aiServiceInstance as any)._parseJsonFromAiResponse(
responseText,
mockLoggerInstance,
);
console.log(' - Actual Output from function:', JSON.stringify(result));
console.log(' - Expected Output:', JSON.stringify([1, 2, 3]));
console.log('--- END TEST LOG ---\n');
@@ -304,22 +433,58 @@ describe('AI Service (Server)', () => {
it('should return null for strings without valid JSON', () => {
const responseText = 'This is just plain text.';
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toBeNull();
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
}
)._parseJsonFromAiResponse(responseText, mockLoggerInstance),
).toBeNull();
});
it('should return null for incomplete JSON and log an error', () => {
const localLogger = createMockLogger();
const localAiServiceInstance = new AIService(localLogger, mockAiClient, mockFileSystem);
const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
expect((localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger)).toBeNull(); // This was a duplicate, fixed.
expect(localLogger.error).toHaveBeenCalledWith(expect.objectContaining({ jsonSlice: '{ "key": "value"' }), "[_parseJsonFromAiResponse] Failed to parse JSON slice.");
expect(
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
).toBeNull(); // This was a duplicate, fixed.
expect(localLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
);
});
});
describe('_normalizeExtractedItems (private method)', () => {
it('should replace null or undefined fields with default values', () => {
const rawItems: { item: string; price_display: null; quantity: undefined; category_name: null; master_item_id: null; }[] = [{ item: 'Test', price_display: null, quantity: undefined, category_name: null, master_item_id: null }];
const [normalized] = (aiServiceInstance as unknown as { _normalizeExtractedItems: (items: typeof rawItems) => { price_display: string, quantity: string, category_name: string, master_item_id: undefined }[] })._normalizeExtractedItems(rawItems);
const rawItems: {
item: string;
price_display: null;
quantity: undefined;
category_name: null;
master_item_id: null;
}[] = [
{
item: 'Test',
price_display: null,
quantity: undefined,
category_name: null,
master_item_id: null,
},
];
const [normalized] = (
aiServiceInstance as unknown as {
_normalizeExtractedItems: (
items: typeof rawItems,
) => {
price_display: string;
quantity: string;
category_name: string;
master_item_id: undefined;
}[];
}
)._normalizeExtractedItems(rawItems);
expect(normalized.price_display).toBe('');
expect(normalized.quantity).toBe('');
expect(normalized.category_name).toBe('Other/Miscellaneous');
@@ -340,7 +505,13 @@ describe('AI Service (Server)', () => {
// Mock AI response
mockAiClient.generateContent.mockResolvedValue({ text: 'Super Store', candidates: [] });
const result = await aiServiceInstance.extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType, mockLoggerInstance);
const result = await aiServiceInstance.extractTextFromImageArea(
imagePath,
'image/jpeg',
cropArea,
extractionType,
mockLoggerInstance,
);
expect(mockSharp).toHaveBeenCalledWith(imagePath);
expect(mockExtract).toHaveBeenCalledWith({
@@ -351,7 +522,7 @@ describe('AI Service (Server)', () => {
});
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
interface AiCallArgs {
contents: {
parts: {
@@ -361,37 +532,59 @@ describe('AI Service (Server)', () => {
}[];
}
const aiCallArgs = mockAiClient.generateContent.mock.calls[0][0] as AiCallArgs;
expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?');
expect(aiCallArgs.contents[0].parts[0].text).toContain(
'What is the store name in this image?',
);
expect(result.text).toBe('Super Store');
});
it('should throw an error if the AI API call fails', async () => {
console.log("TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)");
console.log(
"TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)",
);
const apiError = new Error('API Error');
mockAiClient.generateContent.mockRejectedValue(apiError);
mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data'));
await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates', mockLoggerInstance))
.rejects.toThrow(apiError);
await expect(
aiServiceInstance.extractTextFromImageArea(
'path',
'image/jpeg',
{ x: 0, y: 0, width: 10, height: 10 },
'dates',
mockLoggerInstance,
),
).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, `[extractTextFromImageArea] An error occurred for type dates.`
{ err: apiError },
`[extractTextFromImageArea] An error occurred for type dates.`,
);
});
});
describe('planTripWithMaps', () => {
const mockUserLocation: GeolocationCoordinates = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: () => ({}) };
const mockUserLocation: GeolocationCoordinates = {
latitude: 45,
longitude: -75,
accuracy: 10,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({}),
};
const mockStore = { name: 'Test Store' };
it('should throw a "feature disabled" error', async () => {
// This test verifies the current implementation which has the feature disabled.
await expect(aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance))
.rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
await expect(
aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance),
).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
// Also verify that the warning is logged
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
"[AIService] planTripWithMaps called, but feature is disabled. Throwing error."
'[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
);
});
});
});
});

View File

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