import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({ mockFind: vi.fn(), mockFindOne: vi.fn(), mockFindOneAndUpdate: vi.fn(), mockSave: vi.fn(), })); vi.mock('../../schemas/recipe.schema.js', () => { const chain = () => ({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockReturnThis(), exec: mockFind, }); const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne, }); const updateChain = () => ({ exec: mockFindOneAndUpdate, }); class FakeModel { data: unknown; constructor(data: unknown) { this.data = data; } save() { mockSave(this.data); return Promise.resolve({ toObject: () => this.data }); } static find = vi.fn(() => chain()); static findOne = vi.fn(() => findOneChain()); static findOneAndUpdate = vi.fn(() => updateChain()); } return { RecipeModel: FakeModel }; }); import { RecipesRepository } from './recipes.repository.js'; describe(RecipesRepository.name, () => { let repo: RecipesRepository; beforeEach(() => { vi.clearAllMocks(); repo = new RecipesRepository(); }); describe('findByHousehold', () => { it('returns paginated list without filters', async () => { const items = [{ _id: { toString: () => 'r1' }, name: 'Recipe 1' }]; mockFind.mockResolvedValue(items); const result = await repo.findByHousehold('hh1', { limit: 20 }); expect(result.data).toHaveLength(1); expect(result.pagination.hasMore).toBe(false); }); it('returns hasMore when more items exist', async () => { const items = Array.from({ length: 3 }, (_, i) => ({ _id: { toString: () => `r${i}` }, name: `Recipe ${i}`, })); mockFind.mockResolvedValue(items); const result = await repo.findByHousehold('hh1', { limit: 2 }); expect(result.data).toHaveLength(2); expect(result.pagination.hasMore).toBe(true); expect(result.pagination.cursor).toBeTruthy(); }); it('applies text search filter', async () => { mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { q: 'pasta', limit: 20 }); // No error thrown means the $text filter was applied expect(mockFind).toHaveBeenCalled(); }); it('applies cuisine filter', async () => { mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { cuisine: 'Italian', limit: 20 }); expect(mockFind).toHaveBeenCalled(); }); it('applies isFavorite filter', async () => { mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { isFavorite: true, limit: 20 }); expect(mockFind).toHaveBeenCalled(); }); it('applies maxCalories filter', async () => { mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { maxCalories: 500, limit: 20 }); expect(mockFind).toHaveBeenCalled(); }); it('applies tags filter', async () => { mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { tags: 'vegetarian,quick', limit: 20 }); expect(mockFind).toHaveBeenCalled(); }); it('skips empty tags', async () => { mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { tags: ',', limit: 20 }); expect(mockFind).toHaveBeenCalled(); }); it('applies cursor for pagination', async () => { const cursor = Buffer.from('abc123').toString('base64'); mockFind.mockResolvedValue([]); await repo.findByHousehold('hh1', { cursor, limit: 20 }); expect(mockFind).toHaveBeenCalled(); }); }); describe('findById', () => { it('returns recipe when found', async () => { const recipe = { _id: 'r1', householdId: 'hh1', name: 'Recipe' }; mockFindOne.mockResolvedValue(recipe); const result = await repo.findById('r1', 'hh1'); expect(result).toEqual(recipe); }); it('returns null when not found', async () => { mockFindOne.mockResolvedValue(null); const result = await repo.findById('missing', 'hh1'); expect(result).toBeNull(); }); }); describe('findByProductId', () => { it('returns recipes containing the product', async () => { const items = [{ _id: { toString: () => 'r1' }, name: 'Recipe' }]; mockFind.mockResolvedValue(items); const result = await repo.findByProductId('hh1', 'p1', { limit: 20 }); expect(result.data).toHaveLength(1); }); it('supports cursor pagination', async () => { const cursor = Buffer.from('r1').toString('base64'); mockFind.mockResolvedValue([]); const result = await repo.findByProductId('hh1', 'p1', { cursor, limit: 20 }); expect(result.pagination.hasMore).toBe(false); }); it('defaults limit to 20', async () => { mockFind.mockResolvedValue([]); const result = await repo.findByProductId('hh1', 'p1', {}); expect(result.pagination.hasMore).toBe(false); }); }); describe('findAllByProductId', () => { it('returns all recipes with the product', async () => { const recipes = [{ _id: 'r1' }, { _id: 'r2' }]; mockFind.mockResolvedValue(recipes); const result = await repo.findAllByProductId('hh1', 'p1'); expect(result).toHaveLength(2); }); }); describe('create', () => { it('saves and returns the recipe', async () => { const data = { name: 'New Recipe', servings: 2, steps: [], tags: [], isFavorite: false }; const computed = { ingredients: [], totalNutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 }, perServingNutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 }, warnings: [], }; const result = await repo.create(data, computed, 'hh1', 'user-1'); expect(mockSave).toHaveBeenCalled(); expect(result).toMatchObject({ name: 'New Recipe' }); }); }); describe('update', () => { it('updates and returns the recipe', async () => { const updated = { _id: 'r1', name: 'Updated' }; mockFindOneAndUpdate.mockResolvedValue(updated); const result = await repo.update('r1', 'hh1', { name: 'Updated' }); expect(result).toEqual(updated); }); it('applies computed fields when provided', async () => { mockFindOneAndUpdate.mockResolvedValue({ _id: 'r1' }); await repo.update( 'r1', 'hh1', {}, { totalNutrition: { calories: 100, protein: 10, carbs: 5, fat: 3 }, perServingNutrition: { calories: 100, protein: 10, carbs: 5, fat: 3 }, warnings: [], }, ); expect(mockFindOneAndUpdate).toHaveBeenCalled(); }); }); describe('softDelete', () => { it('sets deletedAt and returns', async () => { const deleted = { _id: 'r1', deletedAt: new Date() }; mockFindOneAndUpdate.mockResolvedValue(deleted); const result = await repo.softDelete('r1', 'hh1'); expect(result).toEqual(deleted); }); }); });