2026-05-14 14:47:23 +09:00
|
|
|
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(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
vi.mock('../../../src/schemas/recipe.schema.js', () => {
|
2026-05-14 14:47:23 +09:00
|
|
|
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 };
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import { RecipesRepository } from '../../../src/modules/recipes/recipes.repository.js';
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|