ADR1-3 on routes + db files
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m30s

This commit is contained in:
2025-12-12 16:09:59 -08:00
parent e37a32c890
commit 117f034b2b
32 changed files with 934 additions and 812 deletions

View File

@@ -1,3 +1,4 @@
// src/services/db/user.db.test.ts
// --- FIX REGISTRY ---
//
// 2025-12-09: Corrected transaction rollback tests to expect generic error messages.
@@ -5,8 +6,9 @@
// mocking strategies for internal method calls (spying on prototypes).
//
// --- END FIX REGISTRY ---
// src/services/db/user.db.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PoolClient } from 'pg';
import { withTransaction } from './connection.db';
// Mock the logger to prevent stderr noise during tests
vi.mock('../logger.server', () => ({
@@ -21,10 +23,15 @@ vi.mock('../logger.server', () => ({
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./user.db');
// Mock the withTransaction helper since we are testing a function that uses it.
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
import { UserRepository, exportUserData } from './user.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Profile, ActivityLogItem, SearchQuery } from '../../types';
// Mock other db services that are used by functions in user.db.ts
@@ -53,6 +60,9 @@ describe('User DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
userRepo = new UserRepository(mockPoolInstance as any);
// Reset the withTransaction mock before each test
const { withTransaction } = require('./connection.db'); // eslint-disable-line @typescript-eslint/no-var-requires
vi.mocked(withTransaction).mockImplementation(async (callback: (client: PoolClient) => Promise<any>) => callback(mockPoolInstance as any));
});
describe('findUserByEmail', () => {
@@ -84,75 +94,55 @@ describe('User DB Service', () => {
it('should execute a transaction to create a user and profile', async () => {
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
const mockProfile = { ...mockUser, role: 'user' };
// For transactional methods, we mock the client returned by `connect()`
const mockClient = {
query: vi.fn(),
release: vi.fn(),
};
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Mock the sequence of queries within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockResolvedValueOnce({ rows: [mockProfile] }) // SELECT profile
.mockResolvedValueOnce({ rows: [] }) // COMMIT;
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockResolvedValueOnce({ rows: [mockProfile] }); // SELECT profile
return callback(mockClient as any);
});
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' });
expect(result).toEqual(mockProfile);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
expect(withTransaction).toHaveBeenCalledTimes(1);
});
it('should rollback the transaction if creating the user fails', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
const dbError = new Error('User insert failed');
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockRejectedValueOnce(dbError); // set_config or INSERT fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
// Arrange: Mock the user insert query to fail after BEGIN and set_config
mockClient.query
.mockRejectedValueOnce(new Error('User insert failed')); // INSERT fails
// Act & Assert
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('User insert failed');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); // This will be called inside the try block
// The createUser function now throws the original error, so we check for that.
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('Failed to create user in database.');
});
it('should rollback the transaction if fetching the final profile fails', async () => {
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
// FIX: Define mockClient within this test's scope.
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockRejectedValueOnce(new Error('Profile fetch failed')); // SELECT profile fails
const dbError = new Error('Profile fetch failed');
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockRejectedValueOnce(dbError); // SELECT profile fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
await expect(userRepo.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Profile fetch failed');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
await expect(userRepo.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Failed to create user in database.');
});
it('should throw UniqueConstraintError if the email already exists', async () => {
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as any).code = '23505';
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Simulate the transaction flow:
// 1. BEGIN (success)
// 2. set_config (success)
// 3. INSERT user (failure with unique violation)
// 4. ROLLBACK (success)
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockRejectedValueOnce(dbError) // INSERT fails
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
vi.mocked(withTransaction).mockRejectedValue(dbError);
try {
await userRepo.createUser('exists@example.com', 'pass', {});
@@ -162,8 +152,7 @@ describe('User DB Service', () => {
expect(error.message).toBe('A user with this email address already exists.');
}
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});
@@ -198,16 +187,9 @@ describe('User DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']);
});
it('should return undefined if user is not found', async () => {
it('should throw NotFoundError if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserById('not-found-id');
expect(result).toBeUndefined();
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserWithPasswordHashById('not-found-id');
expect(result).toBeUndefined();
await expect(userRepo.findUserById('not-found-id')).rejects.toThrow(NotFoundError);
});
it('should throw a generic error if the database query fails', async () => {
@@ -239,12 +221,16 @@ describe('User DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']);
});
it('should return undefined if user profile is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserProfileById('not-found-id');
expect(result).toBeUndefined();
it('should throw NotFoundError if user profile is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(userRepo.findUserById('not-found-id')).rejects.toThrow('User with ID not-found-id not found.');
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserWithPasswordHashById('not-found-id');
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);
@@ -370,10 +356,10 @@ describe('User DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE refresh_token = $1'), ['a-token']);
});
it('should return undefined if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const result = await userRepo.findUserByRefreshToken('a-token');
expect(result).toBeUndefined();
it('should throw NotFoundError if token is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(userRepo.findUserByRefreshToken('a-token')).rejects.toThrow(NotFoundError);
await expect(userRepo.findUserByRefreshToken('a-token')).rejects.toThrow('User not found for the given refresh token.');
});
});
@@ -440,51 +426,53 @@ describe('User DB Service', () => {
});
describe('exportUserData', () => {
it('should call profile, watched items, and shopping list functions', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Import the mocked withTransaction helper
let withTransaction: any;
beforeEach(async () => {
const connDb = await import('./connection.db');
withTransaction = connDb.withTransaction;
});
// --- FIX: Correctly mock the methods on the prototype of the imported classes ---
it('should call profile, watched items, and shopping list functions', async () => {
const { ShoppingRepository } = await import('./shopping.db');
const { PersonalizationRepository } = await import('./personalization.db');
// We need to spy on the prototypes because these classes are instantiated inside exportUserData
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById')
.mockResolvedValue({ user_id: '123' } as Profile);
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems')
.mockResolvedValue([]);
const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists')
.mockResolvedValue([]);
await exportUserData('123');
// Verify that withTransaction was called
expect(withTransaction).toHaveBeenCalledTimes(1);
// Verify the repository methods were called inside the transaction
expect(findProfileSpy).toHaveBeenCalledWith('123');
expect(getWatchedItemsSpy).toHaveBeenCalledWith('123');
expect(getShoppingListsSpy).toHaveBeenCalledWith('123');
});
it('should throw an error if the user profile is not found', async () => {
// Mock findUserProfileById to return undefined
// This uses the same prototype spy strategy as above, or we can mock the query if we prefer.
// Let's use the prototype spy for consistency in this block.
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockResolvedValue(undefined);
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// 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 a generic one.
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
it('should throw an error if the database query fails', async () => {
// Force a failure in one of the calls
// Arrange: Force a failure in one of the parallel calls
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new Error('DB Error'));
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Act & Assert
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});