// src/services/db/user.db.test.ts import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { PoolClient } from 'pg'; // Mock the logger to prevent stderr noise during tests vi.mock('../logger.server', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); import { logger as mockLogger } from '../logger.server'; // Un-mock the module we are testing to ensure we use the real implementation. vi.unmock('./user.db'); // Mock the withTransaction helper. This is the key fix. vi.mock('./connection.db', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, withTransaction: vi.fn() }; }); import { withTransaction } from './connection.db'; import { UserRepository, exportUserData } from './user.db'; import { mockPoolInstance } from '../../tests/setup/tests-setup-unit'; import { createMockUserProfile, createMockUser } from '../../tests/utils/mockFactories'; import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db'; import type { ActivityLogItem, SearchQuery, UserProfile } from '../../types'; import { ShoppingRepository } from './shopping.db'; import { PersonalizationRepository } from './personalization.db'; // Mock other db services that are used by functions in user.db.ts // 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({}); } }, })); vi.mock('./personalization.db', () => ({ PersonalizationRepository: class { getWatchedItems() { return Promise.resolve([]); } }, })); describe('User DB Service', () => { // Instantiate the repository with the mock pool for each test let userRepo: UserRepository; // Restore mocks after each test to ensure spies on prototypes (like in exportUserData) don't leak afterEach(() => { vi.restoreAllMocks(); }); beforeEach(() => { vi.clearAllMocks(); mockPoolInstance.query.mockReset(); 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) => callback(mockPoolInstance as unknown as PoolClient), ); }); describe('findUserByEmail', () => { it('should execute the correct query and return a user', async () => { const mockUser = { user_id: '123', email: 'test@example.com', password_hash: 'some-hash', failed_login_attempts: 0, last_failed_login: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), refresh_token: null, }; mockPoolInstance.query.mockResolvedValue({ rows: [mockUser] }); 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(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(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', ); }); }); describe('createUser', () => { it('should create a user and profile successfully', async () => { const mockUser = { user_id: 'new-user-id', email: 'new@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; 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: new Date().toISOString(), updated_at: new Date().toISOString(), user_created_at: new Date().toISOString(), user_updated_at: new Date().toISOString(), }; const expectedProfile: UserProfile = { user: { user_id: mockDbProfile.user_id, email: mockDbProfile.email, created_at: mockDbProfile.user_created_at, updated_at: mockDbProfile.user_updated_at, }, full_name: 'New User', avatar_url: null, role: 'user', points: 0, preferences: null, created_at: mockDbProfile.created_at, updated_at: mockDbProfile.updated_at, }; // Mock the sequence of queries on the main pool instance (mockPoolInstance.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user .mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile const result = await userRepo.createUser( 'new@example.com', 'hashedpass', { full_name: 'New User' }, mockLogger, ); expect(result.user.user_id).toEqual(expectedProfile.user.user_id); expect(result.full_name).toEqual(expectedProfile.full_name); expect(result).toEqual(expect.objectContaining(expectedProfile)); }); it('should create a user with a null password hash (e.g. OAuth)', async () => { const mockUser = { user_id: 'oauth-user-id', email: 'oauth@example.com', }; const mockDbProfile = { user_id: 'oauth-user-id', email: 'oauth@example.com', role: 'user', full_name: 'OAuth User', user_created_at: new Date().toISOString(), user_updated_at: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; (mockPoolInstance.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user .mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile const result = await userRepo.createUser( 'oauth@example.com', null, // Pass null for passwordHash { full_name: 'OAuth User' }, mockLogger, ); expect(result.user.email).toBe('oauth@example.com'); expect(mockPoolInstance.query).toHaveBeenCalledWith( 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email', ['oauth@example.com', null], ); }); it('should throw an error if creating the user fails', async () => { const dbError = new Error('User insert failed'); mockPoolInstance.query.mockRejectedValue(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', ); }); it('should throw an error if fetching the final profile fails', async () => { const mockUser = { user_id: 'new-user-id', email: 'new@example.com' }; const dbError = new Error('Profile fetch failed'); (mockPoolInstance.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user .mockRejectedValueOnce(dbError); // SELECT profile fails 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', ); }); it('should throw UniqueConstraintError if the email already exists', async () => { const dbError = new Error('duplicate key value violates unique constraint'); (dbError as Error & { code: string }).code = '23505'; (mockPoolInstance.query as Mock).mockRejectedValue(dbError); await expect( userRepo.createUser('exists@example.com', 'pass', {}, mockLogger), ).rejects.toThrow(UniqueConstraintError); await expect( userRepo.createUser('exists@example.com', 'pass', {}, mockLogger), ).rejects.toThrow('A user with this email address already exists.'); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, email: 'exists@example.com', code: '23505', constraint: undefined, detail: undefined, }, 'Error during createUser', ); }); it('should throw an error if profile is not found after user creation', async () => { const mockUser = { user_id: 'new-user-id', email: 'no-profile@example.com' }; (mockPoolInstance.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds .mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing 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', ); }); }); describe('createUser with PoolClient (else branch)', () => { it('should call _createUser directly when instantiated with a PoolClient', async () => { // Create a mock that simulates a PoolClient (no 'connect' method) const mockPoolClient = { query: vi.fn(), // PoolClient does NOT have 'connect', which is key for testing line 151 }; const mockUser = { user_id: 'poolclient-user-id', email: 'poolclient@example.com', }; const mockDbProfile = { user_id: 'poolclient-user-id', email: 'poolclient@example.com', role: 'user', full_name: 'PoolClient User', avatar_url: null, points: 0, preferences: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), user_created_at: new Date().toISOString(), user_updated_at: new Date().toISOString(), }; (mockPoolClient.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user .mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile // Instantiate with the mock PoolClient (not a Pool) const repoWithClient = new UserRepository(mockPoolClient as any); const result = await repoWithClient.createUser( 'poolclient@example.com', 'hashedpass', { full_name: 'PoolClient User' }, mockLogger, ); expect(result.user.user_id).toBe('poolclient-user-id'); expect(result.full_name).toBe('PoolClient User'); // Verify withTransaction was NOT called since we're already in a transaction expect(withTransaction).not.toHaveBeenCalled(); }); }); describe('_createUser (private)', () => { it('should execute queries in order and return a full user profile', async () => { const mockUser = { user_id: 'private-user-id', email: 'private@example.com', }; const mockDbProfile = { user_id: 'private-user-id', email: 'private@example.com', role: 'user', full_name: 'Private User', avatar_url: null, points: 0, preferences: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), user_created_at: new Date().toISOString(), user_updated_at: new Date().toISOString(), }; const expectedProfile: UserProfile = { user: { user_id: mockDbProfile.user_id, email: mockDbProfile.email, created_at: mockDbProfile.user_created_at, updated_at: mockDbProfile.user_updated_at, }, full_name: 'Private User', avatar_url: null, role: 'user', points: 0, preferences: null, created_at: mockDbProfile.created_at, updated_at: mockDbProfile.updated_at, }; // Mock the sequence of queries on the client (mockPoolInstance.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user .mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile // Access private method for testing const result = await (userRepo as any)._createUser( mockPoolInstance, // Pass the mock client 'private@example.com', 'hashedpass', { full_name: 'Private User' }, mockLogger, ); expect(result).toEqual(expectedProfile); expect(mockPoolInstance.query).toHaveBeenCalledTimes(3); expect(mockPoolInstance.query).toHaveBeenNthCalledWith( 1, "SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify({ full_name: 'Private User' })], ); expect(mockPoolInstance.query).toHaveBeenNthCalledWith( 2, 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email', ['private@example.com', 'hashedpass'], ); expect(mockPoolInstance.query).toHaveBeenNthCalledWith( 3, expect.stringContaining('FROM public.users u'), ['private-user-id'], ); }); it('should throw an error if profile is not found after user creation', async () => { const mockUser = { user_id: 'no-profile-user', email: 'no-profile@example.com' }; (mockPoolInstance.query as Mock) .mockResolvedValueOnce({ rows: [] }) // set_config .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user .mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing await expect( (userRepo as any)._createUser( mockPoolInstance, 'no-profile@example.com', 'pass', {}, mockLogger, ), ).rejects.toThrow('Failed to create or retrieve user profile after registration.'); }); }); describe('findUserWithProfileByEmail', () => { it('should query for a user and their profile by email', async () => { const mockDbResult: any = { user_id: '123', email: 'test@example.com', password_hash: 'hash', refresh_token: 'token', failed_login_attempts: 0, last_failed_login: null, full_name: 'Test User', avatar_url: null, role: 'user' as const, points: 0, preferences: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), user_created_at: new Date().toISOString(), user_updated_at: new Date().toISOString(), address_id: null, }; mockPoolInstance.query.mockResolvedValue({ rows: [mockDbResult] }); const expectedResult = { full_name: 'Test User', avatar_url: null, role: 'user', points: 0, preferences: null, address_id: null, user: { user_id: '123', email: 'test@example.com', created_at: expect.any(String), updated_at: expect.any(String), }, password_hash: 'hash', failed_login_attempts: 0, last_failed_login: null, refresh_token: 'token', }; const result = await userRepo.findUserWithProfileByEmail('test@example.com', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining('JOIN public.profiles'), ['test@example.com'], ); expect(result).toEqual(expect.objectContaining(expectedResult)); }); it('should return undefined if user is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); const result = await userRepo.findUserWithProfileByEmail('notfound@example.com', mockLogger); 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.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', ); }); }); describe('findUserById', () => { it('should query for a user by their ID', async () => { const mockUser = createMockUser({ user_id: '123' }); mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1, }); await userRepo.findUserById('123', mockLogger); 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.', ); }); 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', ); }); }); describe('findUserWithPasswordHashById', () => { it('should query for a user and their password hash by ID', async () => { const mockUser = createMockUser({ user_id: '123' }); const mockUserWithHash = { ...mockUser, password_hash: 'hash' }; mockPoolInstance.query.mockResolvedValue({ rows: [mockUserWithHash], rowCount: 1, }); await userRepo.findUserWithPasswordHashById('123', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining('SELECT user_id, email, password_hash, created_at, updated_at'), ['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.'); }); 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', ); }); }); describe('findUserProfileById', () => { it('should query for a user profile by user ID', async () => { const mockProfile = createMockUserProfile({ user: createMockUser({ user_id: '123' }), }); // The query returns a user object inside, so we need to mock that structure. mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] }); 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'], ); }); 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.', ); }); 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', ); }); }); describe('updateUserProfile', () => { it('should execute an UPDATE query for the user profile', async () => { const mockProfile: any = { 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), ); }); it('should execute an UPDATE query for avatar_url', async () => { const mockProfile: any = { 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'], ); }); it('should execute an UPDATE query for address_id', async () => { const mockProfile: any = { 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'], ); }); it('should fetch the current profile if no update fields are provided', async () => { const mockProfile: UserProfile = createMockUserProfile({ user: createMockUser({ 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] }); 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(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.'); }); 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', ); }); }); describe('updateUserPreferences', () => { it('should execute an UPDATE query for user preferences', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [createMockUserProfile()] }); await userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger); 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.'); }); 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', ); }); }); describe('updateUserPassword', () => { 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'], ); }); 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', ); }); }); describe('deleteUserById', () => { it('should execute a DELETE query for the user', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 1 }); await userRepo.deleteUserById('123', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( 'DELETE FROM public.users WHERE user_id = $1', ['123'], ); }); it('should throw NotFoundError if user does not exist (rowCount === 0)', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 }); await expect(userRepo.deleteUserById('nonexistent', mockLogger)).rejects.toThrow( 'User with ID nonexistent not found.', ); }); 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', ); }); }); describe('saveRefreshToken', () => { 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'], ); }); 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', ); }); }); describe('findUserByRefreshToken', () => { it('should query for a user by their refresh token', async () => { const mockUser = createMockUser({ user_id: '123' }); mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1, }); await userRepo.findUserByRefreshToken('a-token', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining('WHERE refresh_token = $1'), ['a-token'], ); }); it('should return undefined if token is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 }); const result = await userRepo.findUserByRefreshToken('a-token', mockLogger); expect(result).toBeUndefined(); }); 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.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError }, 'Database error in findUserByRefreshToken', ); }); }); describe('deleteRefreshToken', () => { it('should log an error but not throw if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined(); expect(mockPoolInstance.query).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError }, 'Database error in deleteRefreshToken', ); }); }); describe('createPasswordResetToken', () => { it('should execute DELETE and INSERT queries', async () => { const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) }; const expires = new Date(); await userRepo.createPasswordResetToken( '123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient, ); expect(mockClient.query).toHaveBeenCalledWith( 'DELETE FROM public.password_reset_tokens WHERE user_id = $1', ['123'], ); expect(mockClient.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'; const mockClient = { query: vi.fn().mockRejectedValue(dbError) }; await expect( userRepo.createPasswordResetToken( 'non-existent-user', 'hash', new Date(), mockLogger, mockClient as unknown as PoolClient, ), ).rejects.toThrow(ForeignKeyConstraintError); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); const mockClient = { query: vi.fn().mockRejectedValue(dbError) }; const expires = new Date(); await expect( userRepo.createPasswordResetToken( '123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient, ), ).rejects.toThrow('Failed to create password reset token.'); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, userId: '123' }, 'Database error in createPasswordResetToken', ); }); }); describe('getValidResetTokens', () => { 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()'), ); }); 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', ); }); }); describe('deleteResetToken', () => { 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'], ); }); it('should log an error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); await expect(userRepo.deleteResetToken('token-hash', mockLogger)).rejects.toThrow( 'Failed to delete password reset token.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, tokenHash: 'token-hash' }, 'Database error in deleteResetToken', ); }); }); describe('deleteExpiredResetTokens', () => { 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(result).toBe(5); 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', ); }); }); describe('exportUserData', () => { it('should call profile, watched items, and shopping list functions', async () => { const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById'); findProfileSpy.mockResolvedValue( createMockUserProfile({ user: createMockUser({ user_id: '123', email: '123@example.com' }), }), ); const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems'); getWatchedItemsSpy.mockResolvedValue([]); const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists'); getShoppingListsSpy.mockResolvedValue([]); await exportUserData('123', mockLogger); // Verify that withTransaction was called expect(withTransaction).toHaveBeenCalledTimes(1); // Verify the repository methods were called inside the transaction expect(findProfileSpy).toHaveBeenCalledWith('123', expect.any(Object)); expect(getWatchedItemsSpy).toHaveBeenCalledWith('123', expect.any(Object)); expect(getShoppingListsSpy).toHaveBeenCalledWith('123', expect.any(Object)); }); it('should throw NotFoundError if the user profile is not found (throws)', async () => { // 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'), ); // Act & Assert: The outer function catches the NotFoundError and re-throws it. await expect(exportUserData('123', mockLogger)).rejects.toThrow('Profile not found'); expect(withTransaction).toHaveBeenCalledTimes(1); }); it('should throw NotFoundError if findUserProfileById returns undefined', async () => { // Arrange: Mock findUserProfileById to return undefined (falsy) vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockResolvedValue( undefined as never, ); vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems').mockResolvedValue([]); vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists').mockResolvedValue([]); // Act & Assert: The inner check `if (!profile)` should throw NotFoundError await expect(exportUserData('123', mockLogger)).rejects.toThrow( 'User profile not found for data export.', ); 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'), ); // Act & Assert await expect(exportUserData('123', mockLogger)).rejects.toThrow( 'Failed to export user data.', ); expect(withTransaction).toHaveBeenCalledTimes(1); }); }); describe('followUser', () => { it('should execute an INSERT query to create a follow relationship', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); 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'], ); }); it('should throw ForeignKeyConstraintError if a 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.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', ); }); }); describe('unfollowUser', () => { it('should execute a DELETE query to remove a follow relationship', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); 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'], ); }); 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', ); }); }); describe('getUserFeed', () => { it('should execute a SELECT query to get a user feed', async () => { const mockFeedItems: ActivityLogItem[] = [ { activity_log_id: 1, user_id: 'following-1', action: 'recipe_created', display_text: 'Created a new recipe', details: { recipe_id: 1, recipe_name: 'Test Recipe' }, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, ]; mockPoolInstance.query.mockResolvedValue({ rows: mockFeedItems }); const result = await userRepo.getUserFeed('user-123', 10, 0, mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining('FROM public.activity_log al'), ['user-123', 10, 0], ); expect(result).toEqual(mockFeedItems); }); it('should return an empty array if the user feed is empty', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); const result = await userRepo.getUserFeed('user-123', 10, 0, mockLogger); expect(result).toEqual([]); }); 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', ); }); }); describe('logSearchQuery', () => { it('should execute an INSERT query and return the new search query log', async () => { const queryData: Omit = { user_id: 'user-123', query_text: 'best chicken recipes', result_count: 5, was_successful: true, }; const mockLoggedQuery: any = { search_query_id: 1, ...queryData, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; mockPoolInstance.query.mockResolvedValue({ rows: [mockLoggedQuery] }); const result = await userRepo.logSearchQuery(queryData, mockLogger); 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], ); expect(result).toEqual(mockLoggedQuery); }); it('should handle logging a search for an anonymous user', async () => { const queryData = { user_id: null, query_text: 'anonymous search', result_count: 10, was_successful: true, }; const mockLoggedQuery: SearchQuery = { search_query_id: 2, ...queryData, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; mockPoolInstance.query.mockResolvedValue({ rows: [mockLoggedQuery] }); await userRepo.logSearchQuery(queryData, mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), [ null, 'anonymous search', 10, true, ]); }); it('should throw ForeignKeyConstraintError if the user_id does not exist', async () => { const dbError = new Error('violates foreign key constraint'); (dbError as Error & { code: string }).code = '23503'; mockPoolInstance.query.mockRejectedValue(dbError); const queryData = { user_id: 'non-existent-user', query_text: 'search text', result_count: 0, was_successful: false, }; await expect(userRepo.logSearchQuery(queryData, mockLogger)).rejects.toThrow( ForeignKeyConstraintError, ); await expect(userRepo.logSearchQuery(queryData, mockLogger)).rejects.toThrow( 'The specified user does not exist.', ); expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ err: dbError, queryData }), 'Database error in logSearchQuery', ); }); 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', ); }); }); });