All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Links im großen Suchfeld ein Slider-Icon mit Badge („Alle" oder „2/5"), das ein Dropdown-Menü mit allen Whitelist-Domains als Checkboxen öffnet. Auswahl wird per localStorage persistiert und gilt global — Header-Such- Dropdown konsumiert den gleichen Store und sendet den domains-Parameter bei jedem Fetch mit. Leere Menge heißt „alle aktiv", damit neu vom Admin freigeschaltete Domains automatisch dabei sind. Aktive Auswahl landet als explizite Intersection mit der Whitelist serverseitig. - searchLocal nimmt jetzt optional string[] domains → `source_domain IN (…)`. - searchWeb nimmt jetzt opts.domains → site:-Filter auf die Auswahl eingeschränkt. Nicht-Whitelist-Einträge werden ignoriert. - API-Endpoints: `?domains=a.de,b.de`. - Neuer Client-Store $lib/client/search-filter.svelte.ts. - Neue Komponente $lib/components/SearchFilter.svelte (mobile-tauglich, 44px Touch-Targets, Badge auf engen Screens versteckt). Home-Seite re-runt die Suche bei Filter-Änderung automatisch (150ms debounce), ohne dass der User neu tippen muss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
|
import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
|
import {
|
|
searchLocal,
|
|
listRecentRecipes,
|
|
listAllRecipes
|
|
} from '../../src/lib/server/recipes/search-local';
|
|
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: [
|
|
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '' }
|
|
]
|
|
})
|
|
);
|
|
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([]);
|
|
});
|
|
|
|
it('filters by domain when supplied', () => {
|
|
const db = openInMemoryForTest();
|
|
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
|
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
|
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']);
|
|
expect(hits.length).toBe(1);
|
|
expect(hits[0].source_domain).toBe('chefkoch.de');
|
|
});
|
|
|
|
it('no domain filter when array is empty', () => {
|
|
const db = openInMemoryForTest();
|
|
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
|
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
|
const hits = searchLocal(db, 'apfel', 10, 0, []);
|
|
expect(hits.length).toBe(2);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|