// src/services/db/recipe.db.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mockPoolInstance } from '../../tests/setup/tests-setup-unit'; import type { Pool } from 'pg'; import { RecipeRepository } from './recipe.db'; // Un-mock the module we are testing to ensure we use the real implementation. vi.unmock('./recipe.db'); // This line is correct. const mockQuery = mockPoolInstance.query; import type { FavoriteRecipe, RecipeComment } from '../../types'; import { createMockRecipe } from '../../tests/utils/mockFactories'; // Mock the logger to prevent console output during tests. This is a server-side DB test. vi.mock('../logger.server', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); import { logger as mockLogger } from '../logger.server'; import { UniqueConstraintError } from './errors.db'; describe('Recipe DB Service', () => { let recipeRepo: RecipeRepository; beforeEach(() => { vi.clearAllMocks(); // Instantiate the repository with the mock pool for each test recipeRepo = new RecipeRepository(mockPoolInstance as unknown as Pool); }); describe('getRecipesBySalePercentage', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await recipeRepo.getRecipesBySalePercentage(50, mockLogger); expect(mockQuery).toHaveBeenCalledWith( 'SELECT * FROM public.get_recipes_by_sale_percentage($1)', [50], ); }); it('should return an empty array if no recipes are found', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await recipeRepo.getRecipesBySalePercentage(50, mockLogger); expect(result).toEqual([]); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.getRecipesBySalePercentage(50, mockLogger)).rejects.toThrow( 'Failed to get recipes by sale percentage.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, minPercentage: 50 }, 'Database error in getRecipesBySalePercentage', ); }); }); describe('getRecipesByMinSaleIngredients', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger); expect(mockQuery).toHaveBeenCalledWith( 'SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [3], ); }); it('should return an empty array if no recipes are found', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger); expect(result).toEqual([]); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger)).rejects.toThrow( 'Failed to get recipes by minimum sale ingredients.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, minIngredients: 3 }, 'Database error in getRecipesByMinSaleIngredients', ); }); }); describe('findRecipesByIngredientAndTag', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger); expect(mockQuery).toHaveBeenCalledWith( 'SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', ['chicken', 'quick'], ); }); it('should return an empty array if no recipes are found', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger); expect(result).toEqual([]); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect( recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger), ).rejects.toThrow('Failed to find recipes by ingredient and tag.'); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, ingredient: 'chicken', tag: 'quick' }, 'Database error in findRecipesByIngredientAndTag', ); }); }); describe('getUserFavoriteRecipes', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await recipeRepo.getUserFavoriteRecipes('user-123', mockLogger); expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', [ 'user-123', ]); }); it('should return an empty array if user has no favorites', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await recipeRepo.getUserFavoriteRecipes('user-123', mockLogger); expect(result).toEqual([]); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.getUserFavoriteRecipes('user-123', mockLogger)).rejects.toThrow( 'Failed to get favorite recipes.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, userId: 'user-123' }, 'Database error in getUserFavoriteRecipes', ); }); }); describe('addFavoriteRecipe', () => { it('should execute an INSERT query and return the new favorite', async () => { const mockFavorite: FavoriteRecipe = { user_id: 'user-123', recipe_id: 1, created_at: new Date().toISOString(), }; // This line is correct. mockQuery.mockResolvedValue({ rows: [mockFavorite], rowCount: 1 }); const result = await recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO public.favorite_recipes'), ['user-123', 1], ); expect(result).toEqual(mockFavorite); }); it('should throw ForeignKeyConstraintError if user or recipe does not exist', async () => { const dbError = new Error('violates foreign key constraint'); (dbError as Error & { code: string }).code = '23503'; mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.addFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow( 'The specified user or recipe does not exist.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, userId: 'user-123', recipeId: 999, code: '23503', constraint: undefined, detail: undefined, }, 'Database error in addFavoriteRecipe', ); }); it('should throw UniqueConstraintError if the favorite already exists (ON CONFLICT)', async () => { // When ON CONFLICT DO NOTHING happens, the RETURNING clause does not execute, so rows is empty. // The implementation now throws an error when rowCount is 0. mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }); await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow( UniqueConstraintError, ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow( 'Failed to add favorite recipe.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, userId: 'user-123', recipeId: 1 }, 'Database error in addFavoriteRecipe', ); }); }); describe('removeFavoriteRecipe', () => { it('should execute a DELETE query', async () => { mockQuery.mockResolvedValue({ rowCount: 1 }); await recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger); expect(mockQuery).toHaveBeenCalledWith( 'DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1], ); }); it('should throw an error if the favorite recipe is not found', async () => { // Simulate the DB returning 0 rows affected mockQuery.mockResolvedValue({ rowCount: 0 }); await expect(recipeRepo.removeFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow( 'Favorite recipe not found for this user.', ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow( 'Failed to remove favorite recipe.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, userId: 'user-123', recipeId: 1 }, 'Database error in removeFavoriteRecipe', ); }); }); describe('deleteRecipe', () => { it('should execute a DELETE query with user ownership check', async () => { mockQuery.mockResolvedValue({ rowCount: 1 }); await recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger); expect(mockQuery).toHaveBeenCalledWith( 'DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2', [1, 'user-123'], ); }); it('should execute a DELETE query without user ownership check if isAdmin is true', async () => { mockQuery.mockResolvedValue({ rowCount: 1 }); await recipeRepo.deleteRecipe(1, 'admin-user', true, mockLogger); expect(mockQuery).toHaveBeenCalledWith( 'DELETE FROM public.recipes WHERE recipe_id = $1', [1], ); }); it('should throw an error if the recipe is not found or not owned by the user', async () => { mockQuery.mockResolvedValue({ rowCount: 0 }); await expect(recipeRepo.deleteRecipe(999, 'user-123', false, mockLogger)).rejects.toThrow( 'Recipe not found or user does not have permission to delete.', ); }); it('should throw a generic error if the database query fails', async () => { mockQuery.mockRejectedValue(new Error('DB Error')); await expect(recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger)).rejects.toThrow( 'Failed to delete recipe.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: expect.any(Error), recipeId: 1, userId: 'user-123', isAdmin: false }, 'Database error in deleteRecipe', ); }); }); describe('deleteRecipe - Ownership Check', () => { it('should not delete recipe if the user does not own it and is not an admin', async () => { mockQuery.mockResolvedValue({ rowCount: 0 }); await expect(recipeRepo.deleteRecipe(1, 'wrong-user', false, mockLogger)).rejects.toThrow( 'Recipe not found or user does not have permission to delete.', ); }); }); describe('updateRecipe', () => { it('should execute an UPDATE query with the correct fields', async () => { const mockRecipe = createMockRecipe({ recipe_id: 1, name: 'Updated Recipe', description: 'New desc', }); mockQuery.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 }); const updates = { name: 'Updated Recipe', description: 'New desc' }; const result = await recipeRepo.updateRecipe(1, 'user-123', updates, mockLogger); expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), [ 'Updated Recipe', 'New desc', 1, 'user-123', ]); expect(result).toEqual(mockRecipe); }); it('should throw an error if no fields are provided to update', async () => { await expect(recipeRepo.updateRecipe(1, 'user-123', {}, mockLogger)).rejects.toThrow( 'No fields provided to update.', ); }); it('should throw an error if the recipe is not found or not owned by the user', async () => { mockQuery.mockResolvedValue({ rowCount: 0 }); await expect( recipeRepo.updateRecipe(999, 'user-123', { name: 'Fail' }, mockLogger), ).rejects.toThrow('Recipe not found or user does not have permission to update.'); }); it('should throw a generic error if the database query fails', async () => { mockQuery.mockRejectedValue(new Error('DB Error')); await expect( recipeRepo.updateRecipe(1, 'user-123', { name: 'Fail' }, mockLogger), ).rejects.toThrow('Failed to update recipe.'); expect(mockLogger.error).toHaveBeenCalledWith( { err: expect.any(Error), recipeId: 1, userId: 'user-123', updates: { name: 'Fail' } }, 'Database error in updateRecipe', ); }); }); describe('getRecipeById', () => { it('should execute a SELECT query and return the recipe', async () => { const mockRecipe = createMockRecipe({ recipe_id: 1, name: 'Test Recipe' }); mockQuery.mockResolvedValue({ rows: [mockRecipe] }); const result = await recipeRepo.getRecipeById(1, mockLogger); expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipes r'), [1]); expect(result).toEqual(mockRecipe); }); it('should throw NotFoundError if recipe is not found', async () => { mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }); await expect(recipeRepo.getRecipeById(999, mockLogger)).rejects.toThrow( 'Recipe with ID 999 not found', ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.getRecipeById(1, mockLogger)).rejects.toThrow( 'Failed to retrieve recipe.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, recipeId: 1 }, 'Database error in getRecipeById', ); }); }); describe('getRecipeComments', () => { it('should execute a SELECT query with a JOIN', async () => { mockQuery.mockResolvedValue({ rows: [] }); await recipeRepo.getRecipeComments(1, mockLogger); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('FROM public.recipe_comments rc'), [1], ); }); it('should return an empty array if recipe has no comments', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await recipeRepo.getRecipeComments(1, mockLogger); expect(result).toEqual([]); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.getRecipeComments(1, mockLogger)).rejects.toThrow( 'Failed to get recipe comments.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, recipeId: 1 }, 'Database error in getRecipeComments', ); }); }); describe('addRecipeComment', () => { it('should execute an INSERT query and return the new comment', async () => { const mockComment: RecipeComment = { recipe_comment_id: 1, recipe_id: 1, user_id: 'user-123', content: 'Great!', status: 'visible', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; mockQuery.mockResolvedValue({ rows: [mockComment] }); const result = await recipeRepo.addRecipeComment(1, 'user-123', 'Great!', mockLogger); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO public.recipe_comments'), [1, 'user-123', 'Great!', undefined], ); expect(result).toEqual(mockComment); }); it('should throw ForeignKeyConstraintError if recipe, user, or parent comment does not exist', async () => { const dbError = new Error('violates foreign key constraint'); (dbError as Error & { code: string }).code = '23503'; mockQuery.mockRejectedValue(dbError); await expect( recipeRepo.addRecipeComment(999, 'user-123', 'Fail', mockLogger), ).rejects.toThrow('The specified recipe, user, or parent comment does not exist.'); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, recipeId: 999, userId: 'user-123', parentCommentId: undefined, code: '23503', constraint: undefined, detail: undefined, }, 'Database error in addRecipeComment', ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.addRecipeComment(1, 'user-123', 'Fail', mockLogger)).rejects.toThrow( 'Failed to add recipe comment.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, recipeId: 1, userId: 'user-123', parentCommentId: undefined }, 'Database error in addRecipeComment', ); }); }); describe('forkRecipe', () => { it('should call the fork_recipe database function', async () => { const mockRecipe = createMockRecipe({ recipe_id: 2, name: 'Forked Recipe' }); mockQuery.mockResolvedValue({ rows: [mockRecipe] }); const result = await recipeRepo.forkRecipe('user-123', 1, mockLogger); expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', [ 'user-123', 1, ]); expect(result).toEqual(mockRecipe); }); it('should re-throw the specific error message from the database function', async () => { const dbError = new Error('Recipe is not public and cannot be forked.'); (dbError as Error & { code: string }).code = 'P0001'; // raise_exception mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow( 'Recipe is not public and cannot be forked.', ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockQuery.mockRejectedValue(dbError); await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow( 'Failed to fork recipe.', ); expect(mockLogger.error).toHaveBeenCalledWith( { err: dbError, userId: 'user-123', originalRecipeId: 1 }, 'Database error in forkRecipe', ); }); }); });