Files
kochwas/tests/integration/search-local.test.ts
hsiegeln a62b32aa1e
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
feat(search): „+ weitere Ergebnisse"-Button für lokale und Web-Suche
Die Ergebnislisten waren oft kurz, weil lokale Suche auf LIMIT 30 und
die Web-Suche auf die erste SearXNG-Seite beschränkt war. Jetzt lässt
sich beides nachladen.

- `searchLocal` nimmt jetzt einen `offset` und der `/api/recipes/search`-
  Endpoint einen `?offset=`-Parameter.
- `searchWeb` nimmt jetzt eine `pageno`-Option und reicht sie als
  `pageno`-Parameter an SearXNG weiter. `pageno=1` wird weggelassen,
  damit bestehendes Verhalten unverändert bleibt.
- `/search` und `/search/web` zeigen unterhalb der Liste einen
  „+ weitere Ergebnisse"-Button. Beide deduplizieren nachgeladene
  Hits (ID bzw. URL), weil SearXNG das gleiche Ergebnis auf zwei
  Seiten liefern kann.

Kein Endless-Scroll: expliziter Button ist mobil robuster und spart
die teure Thumbnail-Enrichment-Roundtrip-Zeit, die bei jeder neuen
Web-Seite anfällt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:58:47 +02:00

133 lines
4.2 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('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);
});
});