All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m59s
486 lines
19 KiB
TypeScript
486 lines
19 KiB
TypeScript
// 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',
|
|
);
|
|
});
|
|
});
|
|
});
|