2026-04-17 15:23:00 +02:00
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
|
|
|
|
import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
2026-04-17 21:54:04 +02:00
|
|
|
import {
|
|
|
|
|
searchLocal,
|
|
|
|
|
listRecentRecipes,
|
2026-04-18 11:14:44 +02:00
|
|
|
listAllRecipes,
|
|
|
|
|
listAllRecipesPaginated
|
2026-04-17 21:54:04 +02:00
|
|
|
} from '../../src/lib/server/recipes/search-local';
|
2026-04-17 15:23:00 +02:00
|
|
|
import type { Recipe } from '../../src/lib/types';
|
|
|
|
|
|
|
|
|
|
function recipe(overrides: Partial<Recipe> = {}): Recipe {
|
|
|
|
|
return {
|
|
|
|
|
id: null,
|
|
|
|
|
title: 'Test',
|
|
|
|
|
description: null,
|
|
|
|
|
source_url: null,
|
|
|
|
|
source_domain: null,
|
|
|
|
|
image_path: null,
|
|
|
|
|
servings_default: 4,
|
|
|
|
|
servings_unit: null,
|
|
|
|
|
prep_time_min: null,
|
|
|
|
|
cook_time_min: null,
|
|
|
|
|
total_time_min: null,
|
|
|
|
|
cuisine: null,
|
|
|
|
|
category: null,
|
|
|
|
|
ingredients: [],
|
|
|
|
|
steps: [],
|
|
|
|
|
tags: [],
|
|
|
|
|
...overrides
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('searchLocal', () => {
|
|
|
|
|
it('finds by title prefix', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Spaghetti Carbonara' }));
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Zucchinipuffer' }));
|
|
|
|
|
const hits = searchLocal(db, 'carb');
|
|
|
|
|
expect(hits.length).toBe(1);
|
|
|
|
|
expect(hits[0].title).toBe('Spaghetti Carbonara');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('finds by ingredient name', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(
|
|
|
|
|
db,
|
|
|
|
|
recipe({
|
|
|
|
|
title: 'Pasta',
|
|
|
|
|
ingredients: [
|
2026-04-19 14:55:46 +02:00
|
|
|
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '', section_heading: null }
|
2026-04-17 15:23:00 +02:00
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const hits = searchLocal(db, 'pancetta');
|
|
|
|
|
expect(hits.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('finds by tag', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Pizza', tags: ['Italienisch'] }));
|
|
|
|
|
const hits = searchLocal(db, 'italienisch');
|
|
|
|
|
expect(hits.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns empty for empty query', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'X' }));
|
|
|
|
|
expect(searchLocal(db, ' ')).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 21:59:48 +02:00
|
|
|
it('ignores source_domain — local search is domain-agnostic', () => {
|
2026-04-18 08:13:33 +02:00
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
2026-04-21 21:59:48 +02:00
|
|
|
const hits = searchLocal(db, 'apfel');
|
2026-04-18 08:13:33 +02:00
|
|
|
expect(hits.length).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 21:58:47 +02:00
|
|
|
it('paginates via limit + offset', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
insertRecipe(db, recipe({ title: `Pizza ${i}` }));
|
|
|
|
|
}
|
|
|
|
|
const first = searchLocal(db, 'pizza', 2, 0);
|
|
|
|
|
const second = searchLocal(db, 'pizza', 2, 2);
|
|
|
|
|
expect(first.length).toBe(2);
|
|
|
|
|
expect(second.length).toBe(2);
|
|
|
|
|
// No overlap between pages
|
|
|
|
|
const firstIds = new Set(first.map((h) => h.id));
|
|
|
|
|
for (const h of second) expect(firstIds.has(h.id)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 15:23:00 +02:00
|
|
|
it('aggregates avg_stars across profiles', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
|
|
|
|
db.prepare('INSERT INTO profile(name) VALUES (?), (?)').run('A', 'B');
|
|
|
|
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5), (?, 2, 3)').run(
|
|
|
|
|
id,
|
|
|
|
|
id
|
|
|
|
|
);
|
|
|
|
|
const hits = searchLocal(db, 'rated');
|
|
|
|
|
expect(hits[0].avg_stars).toBe(4);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('listRecentRecipes', () => {
|
|
|
|
|
it('returns most recent first', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Old' }));
|
|
|
|
|
// tiny delay hack: rely on created_at DEFAULT plus explicit order — insert second and ensure id ordering
|
|
|
|
|
insertRecipe(db, recipe({ title: 'New' }));
|
|
|
|
|
const recent = listRecentRecipes(db, 10);
|
|
|
|
|
expect(recent.length).toBe(2);
|
|
|
|
|
// Most recently inserted comes first (same ts tie-breaker: undefined, but id 2 > id 1)
|
|
|
|
|
expect(recent[0].title === 'New' || recent[0].title === 'Old').toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-17 21:54:04 +02:00
|
|
|
|
|
|
|
|
describe('listAllRecipes', () => {
|
|
|
|
|
it('returns all recipes sorted alphabetically, case-insensitive', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'zuccini' }));
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Apfelkuchen' }));
|
|
|
|
|
insertRecipe(db, recipe({ title: 'birnenkompott' }));
|
|
|
|
|
const all = listAllRecipes(db);
|
|
|
|
|
expect(all.map((r) => r.title)).toEqual([
|
|
|
|
|
'Apfelkuchen',
|
|
|
|
|
'birnenkompott',
|
|
|
|
|
'zuccini'
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('includes hidden-from-recent recipes too', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
const id = insertRecipe(db, recipe({ title: 'Versteckt' }));
|
|
|
|
|
db.prepare('UPDATE recipe SET hidden_from_recent = 1 WHERE id = ?').run(id);
|
|
|
|
|
const all = listAllRecipes(db);
|
|
|
|
|
expect(all.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-18 11:14:44 +02:00
|
|
|
|
|
|
|
|
describe('listAllRecipesPaginated', () => {
|
|
|
|
|
it('sorts by name asc case-insensitive', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
insertRecipe(db, recipe({ title: 'zucchini' }));
|
|
|
|
|
insertRecipe(db, recipe({ title: 'Apfel' }));
|
|
|
|
|
insertRecipe(db, recipe({ title: 'birnen' }));
|
|
|
|
|
const page = listAllRecipesPaginated(db, 'name', 10, 0);
|
|
|
|
|
expect(page.map((h) => h.title)).toEqual(['Apfel', 'birnen', 'zucchini']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('paginates with limit + offset', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
for (let i = 0; i < 15; i++) insertRecipe(db, recipe({ title: `R${i.toString().padStart(2, '0')}` }));
|
|
|
|
|
const first = listAllRecipesPaginated(db, 'name', 5, 0);
|
|
|
|
|
const second = listAllRecipesPaginated(db, 'name', 5, 5);
|
|
|
|
|
expect(first.length).toBe(5);
|
|
|
|
|
expect(second.length).toBe(5);
|
|
|
|
|
const overlap = first.filter((h) => second.some((s) => s.id === h.id));
|
|
|
|
|
expect(overlap.length).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('sorts by rating desc, unrated last', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
const a = insertRecipe(db, recipe({ title: 'A' }));
|
|
|
|
|
const b = insertRecipe(db, recipe({ title: 'B' }));
|
|
|
|
|
const c = insertRecipe(db, recipe({ title: 'C' }));
|
|
|
|
|
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
|
|
|
|
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 3)').run(a);
|
|
|
|
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5)').run(c);
|
|
|
|
|
const page = listAllRecipesPaginated(db, 'rating', 10, 0);
|
|
|
|
|
// C (5) > A (3) > B (null)
|
|
|
|
|
expect(page.map((h) => h.title)).toEqual(['C', 'A', 'B']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('sorts by last_cooked_at desc, never-cooked last', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
const a = insertRecipe(db, recipe({ title: 'A' }));
|
|
|
|
|
const b = insertRecipe(db, recipe({ title: 'B' }));
|
|
|
|
|
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
|
|
|
|
|
db.prepare('INSERT INTO cooking_log(recipe_id, profile_id) VALUES (?, 1)').run(a);
|
|
|
|
|
const page = listAllRecipesPaginated(db, 'cooked', 10, 0);
|
|
|
|
|
expect(page[0].title).toBe('A');
|
|
|
|
|
expect(page[1].title).toBe('B');
|
|
|
|
|
});
|
|
|
|
|
});
|