Compare commits
3 Commits
72019f9cb7
...
3b1950713f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b1950713f | |||
| 28e40d763d | |||
| 18547a7301 |
19
src/lib/server/db/migrations/002_wishlist.sql
Normal file
19
src/lib/server/db/migrations/002_wishlist.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Shared family wishlist: recipes someone wants to cook next.
|
||||
-- Each recipe appears at most once; anyone can add/remove and like/unlike.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wishlist (
|
||||
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
|
||||
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
|
||||
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_wishlist_added_at ON wishlist(added_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wishlist_like (
|
||||
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
|
||||
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (recipe_id, profile_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_wishlist_like_recipe ON wishlist_like(recipe_id);
|
||||
98
src/lib/server/wishlist/repository.ts
Normal file
98
src/lib/server/wishlist/repository.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type WishlistEntry = {
|
||||
recipe_id: number;
|
||||
title: string;
|
||||
image_path: string | null;
|
||||
source_domain: string | null;
|
||||
added_by_profile_id: number | null;
|
||||
added_by_name: string | null;
|
||||
added_at: string;
|
||||
like_count: number;
|
||||
liked_by_me: 0 | 1;
|
||||
avg_stars: number | null;
|
||||
};
|
||||
|
||||
export type SortKey = 'popular' | 'newest' | 'oldest';
|
||||
|
||||
export function listWishlist(
|
||||
db: Database.Database,
|
||||
activeProfileId: number | null,
|
||||
sort: SortKey = 'popular'
|
||||
): WishlistEntry[] {
|
||||
const orderBy = {
|
||||
popular: 'like_count DESC, w.added_at DESC',
|
||||
newest: 'w.added_at DESC',
|
||||
oldest: 'w.added_at ASC'
|
||||
}[sort];
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT
|
||||
w.recipe_id,
|
||||
r.title,
|
||||
r.image_path,
|
||||
r.source_domain,
|
||||
w.added_by_profile_id,
|
||||
p.name AS added_by_name,
|
||||
w.added_at,
|
||||
(SELECT COUNT(*) FROM wishlist_like wl WHERE wl.recipe_id = w.recipe_id) AS like_count,
|
||||
CASE
|
||||
WHEN ? IS NULL THEN 0
|
||||
WHEN EXISTS (SELECT 1 FROM wishlist_like wl
|
||||
WHERE wl.recipe_id = w.recipe_id AND wl.profile_id = ?)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS liked_by_me,
|
||||
(SELECT AVG(stars) FROM rating WHERE recipe_id = w.recipe_id) AS avg_stars
|
||||
FROM wishlist w
|
||||
JOIN recipe r ON r.id = w.recipe_id
|
||||
LEFT JOIN profile p ON p.id = w.added_by_profile_id
|
||||
ORDER BY ${orderBy}`
|
||||
)
|
||||
.all(activeProfileId, activeProfileId) as WishlistEntry[];
|
||||
}
|
||||
|
||||
export function isOnWishlist(db: Database.Database, recipeId: number): boolean {
|
||||
return (
|
||||
db
|
||||
.prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ?')
|
||||
.get(recipeId) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function addToWishlist(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number | null
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO wishlist(recipe_id, added_by_profile_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(recipe_id) DO NOTHING`
|
||||
).run(recipeId, profileId);
|
||||
}
|
||||
|
||||
export function removeFromWishlist(db: Database.Database, recipeId: number): void {
|
||||
db.prepare('DELETE FROM wishlist WHERE recipe_id = ?').run(recipeId);
|
||||
}
|
||||
|
||||
export function likeWish(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO wishlist_like(recipe_id, profile_id) VALUES (?, ?)'
|
||||
).run(recipeId, profileId);
|
||||
}
|
||||
|
||||
export function unlikeWish(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): void {
|
||||
db.prepare(
|
||||
'DELETE FROM wishlist_like WHERE recipe_id = ? AND profile_id = ?'
|
||||
).run(recipeId, profileId);
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
<header class="bar">
|
||||
<a href="/" class="brand">Kochwas</a>
|
||||
<div class="bar-right">
|
||||
<a href="/admin" class="admin-link" aria-label="Einstellungen">⚙️</a>
|
||||
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
|
||||
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
|
||||
<ProfileSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
@@ -59,7 +60,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-link {
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -69,7 +70,7 @@
|
||||
text-decoration: none;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.admin-link:hover {
|
||||
.nav-link:hover {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
main {
|
||||
|
||||
40
src/routes/api/wishlist/+server.ts
Normal file
40
src/routes/api/wishlist/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import {
|
||||
addToWishlist,
|
||||
listWishlist,
|
||||
type SortKey
|
||||
} from '$lib/server/wishlist/repository';
|
||||
|
||||
const AddSchema = z.object({
|
||||
recipe_id: z.number().int().positive(),
|
||||
profile_id: z.number().int().positive().nullable().optional()
|
||||
});
|
||||
|
||||
const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const;
|
||||
|
||||
function parseSort(raw: string | null): SortKey {
|
||||
return VALID_SORTS.includes(raw as SortKey) ? (raw as SortKey) : 'popular';
|
||||
}
|
||||
|
||||
function parseProfileId(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const sort = parseSort(url.searchParams.get('sort'));
|
||||
const profileId = parseProfileId(url.searchParams.get('profile_id'));
|
||||
return json({ sort, entries: listWishlist(getDb(), profileId, sort) });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = AddSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id ?? null);
|
||||
return json({ ok: true }, { status: 201 });
|
||||
};
|
||||
16
src/routes/api/wishlist/[recipe_id]/+server.ts
Normal file
16
src/routes/api/wishlist/[recipe_id]/+server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { removeFromWishlist } from '$lib/server/wishlist/repository';
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.recipe_id!);
|
||||
removeFromWishlist(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
31
src/routes/api/wishlist/[recipe_id]/like/+server.ts
Normal file
31
src/routes/api/wishlist/[recipe_id]/like/+server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { likeWish, unlikeWish } from '$lib/server/wishlist/repository';
|
||||
|
||||
const Schema = z.object({ profile_id: z.number().int().positive() });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.recipe_id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
likeWish(getDb(), id, parsed.data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.recipe_id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
unlikeWish(getDb(), id, parsed.data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
let comments = $state<CommentRow[]>([]);
|
||||
let cookingLog = $state<typeof data.cooking_log>([]);
|
||||
let isFav = $state(false);
|
||||
let onWishlist = $state(false);
|
||||
let newComment = $state('');
|
||||
|
||||
$effect(() => {
|
||||
@@ -124,6 +125,31 @@
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function toggleWishlist() {
|
||||
if (onWishlist) {
|
||||
await fetch(`/api/wishlist/${data.recipe.id}`, { method: 'DELETE' });
|
||||
onWishlist = false;
|
||||
} else {
|
||||
await fetch('/api/wishlist', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipe_id: data.recipe.id,
|
||||
profile_id: profileStore.active?.id ?? null
|
||||
})
|
||||
});
|
||||
onWishlist = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWishlistState() {
|
||||
// No dedicated GET for a single entry; scan the list and check.
|
||||
const res = await fetch('/api/wishlist?sort=newest');
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
onWishlist = body.entries.some((e: { recipe_id: number }) => e.recipe_id === data.recipe.id);
|
||||
}
|
||||
|
||||
// Wake-Lock
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
async function requestWakeLock() {
|
||||
@@ -139,6 +165,7 @@
|
||||
onMount(() => {
|
||||
void requestWakeLock();
|
||||
void checkFavorite();
|
||||
void refreshWishlistState();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock();
|
||||
};
|
||||
@@ -171,6 +198,9 @@
|
||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
||||
{isFav ? '♥' : '♡'} Favorit
|
||||
</button>
|
||||
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
|
||||
{onWishlist ? '✓' : '🍽️'} {onWishlist ? 'Auf Wunschliste' : 'Auf Wunschliste setzen'}
|
||||
</button>
|
||||
<button class="btn" onclick={() => window.print()}>🖨 Drucken</button>
|
||||
<button class="btn" onclick={renameRecipe}>✎ Umbenennen</button>
|
||||
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
|
||||
@@ -267,6 +297,11 @@
|
||||
border-color: #f1b4b4;
|
||||
background: #fdf3f3;
|
||||
}
|
||||
.btn.wish {
|
||||
color: #2b6a3d;
|
||||
border-color: #b7d6c2;
|
||||
background: #eaf4ed;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
|
||||
259
src/routes/wishlist/+page.svelte
Normal file
259
src/routes/wishlist/+page.svelte
Normal file
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||
|
||||
let entries = $state<WishlistEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let sort = $state<SortKey>('popular');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const params = new URLSearchParams({ sort });
|
||||
if (profileStore.active) params.set('profile_id', String(profileStore.active.id));
|
||||
const res = await fetch(`/api/wishlist?${params}`);
|
||||
const body = await res.json();
|
||||
entries = body.entries;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-fetch when sort or active profile changes
|
||||
sort;
|
||||
profileStore.activeId;
|
||||
void load();
|
||||
});
|
||||
|
||||
async function toggleLike(entry: WishlistEntry) {
|
||||
if (!profileStore.active) {
|
||||
alert('Bitte Profil wählen, um zu liken.');
|
||||
return;
|
||||
}
|
||||
const method = entry.liked_by_me ? 'DELETE' : 'PUT';
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}/like`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileStore.active.id })
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(entry: WishlistEntry) {
|
||||
if (!confirm(`„${entry.title}" von der Wunschliste entfernen?`)) return;
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function resolveImage(p: string | null): string | null {
|
||||
if (!p) return null;
|
||||
return /^https?:\/\//i.test(p) ? p : `/images/${p}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="head">
|
||||
<h1>Wunschliste</h1>
|
||||
<p class="sub">Das wollen wir bald mal essen.</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<label>
|
||||
Sortieren:
|
||||
<select bind:value={sort}>
|
||||
<option value="popular">Am meisten gewünscht</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Lädt …</p>
|
||||
{:else if entries.length === 0}
|
||||
<section class="empty">
|
||||
<p class="big">🥘</p>
|
||||
<p>Noch nichts gewünscht.</p>
|
||||
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
|
||||
</section>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each entries as e (e.recipe_id)}
|
||||
<li class="card">
|
||||
<a class="body" href={`/recipes/${e.recipe_id}`}>
|
||||
{#if resolveImage(e.image_path)}
|
||||
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<div class="placeholder">🥘</div>
|
||||
{/if}
|
||||
<div class="text">
|
||||
<div class="title">{e.title}</div>
|
||||
<div class="meta">
|
||||
{#if e.added_by_name}
|
||||
<span>von {e.added_by_name}</span>
|
||||
{/if}
|
||||
{#if e.source_domain}
|
||||
<span>· {e.source_domain}</span>
|
||||
{/if}
|
||||
{#if e.avg_stars !== null}
|
||||
<span>· ★ {e.avg_stars.toFixed(1)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="like"
|
||||
class:active={e.liked_by_me}
|
||||
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
|
||||
onclick={() => toggleLike(e)}
|
||||
>
|
||||
{e.liked_by_me ? '♥' : '♡'}
|
||||
{#if e.like_count > 0}
|
||||
<span class="count">{e.like_count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>🗑</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.head {
|
||||
padding: 1.25rem 0 0.5rem;
|
||||
}
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
.sub {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #666;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 0 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
.controls select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
background: white;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.big {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.hint {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
min-height: 96px;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.body img,
|
||||
.placeholder {
|
||||
width: 96px;
|
||||
object-fit: cover;
|
||||
background: #eef3ef;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text {
|
||||
flex: 1;
|
||||
padding: 0.7rem 0.75rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.25rem;
|
||||
color: #888;
|
||||
font-size: 0.82rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem 0.5rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.like,
|
||||
.del {
|
||||
min-width: 48px;
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e4eae7;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.like.active {
|
||||
color: #c53030;
|
||||
background: #fdf3f3;
|
||||
border-color: #f1b4b4;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -30,9 +30,14 @@ describe('db migrations', () => {
|
||||
|
||||
it('is idempotent', () => {
|
||||
const db = openInMemoryForTest();
|
||||
const countBefore = (
|
||||
db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }
|
||||
).c;
|
||||
runMigrations(db);
|
||||
const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number };
|
||||
expect(migs.c).toBe(1);
|
||||
const countAfter = (
|
||||
db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }
|
||||
).c;
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
it('cascades recipe delete to ingredients and steps', () => {
|
||||
|
||||
133
tests/integration/wishlist.test.ts
Normal file
133
tests/integration/wishlist.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||
import { createProfile } from '../../src/lib/server/profiles/repository';
|
||||
import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
||||
import {
|
||||
addToWishlist,
|
||||
removeFromWishlist,
|
||||
listWishlist,
|
||||
isOnWishlist,
|
||||
likeWish,
|
||||
unlikeWish
|
||||
} from '../../src/lib/server/wishlist/repository';
|
||||
import type { Recipe } from '../../src/lib/types';
|
||||
|
||||
const recipe = (title: string, id?: null): Recipe => ({
|
||||
id: id ?? null,
|
||||
title,
|
||||
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: []
|
||||
});
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openInMemoryForTest();
|
||||
});
|
||||
|
||||
describe('wishlist add/remove', () => {
|
||||
it('adds and lists', () => {
|
||||
const r1 = insertRecipe(db, recipe('Carbonara'));
|
||||
const p = createProfile(db, 'Hendrik');
|
||||
addToWishlist(db, r1, p.id);
|
||||
expect(isOnWishlist(db, r1)).toBe(true);
|
||||
const list = listWishlist(db, p.id);
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0].title).toBe('Carbonara');
|
||||
expect(list[0].added_by_name).toBe('Hendrik');
|
||||
});
|
||||
|
||||
it('is idempotent on double-add', () => {
|
||||
const r1 = insertRecipe(db, recipe('Pizza'));
|
||||
const p = createProfile(db, 'A');
|
||||
addToWishlist(db, r1, p.id);
|
||||
addToWishlist(db, r1, p.id);
|
||||
expect(listWishlist(db, p.id).length).toBe(1);
|
||||
});
|
||||
|
||||
it('removes', () => {
|
||||
const r1 = insertRecipe(db, recipe('X'));
|
||||
addToWishlist(db, r1, null);
|
||||
removeFromWishlist(db, r1);
|
||||
expect(listWishlist(db, null).length).toBe(0);
|
||||
});
|
||||
|
||||
it('cascades with recipe delete', () => {
|
||||
const r1 = insertRecipe(db, recipe('X'));
|
||||
addToWishlist(db, r1, null);
|
||||
db.prepare('DELETE FROM recipe WHERE id = ?').run(r1);
|
||||
expect(listWishlist(db, null).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wishlist likes + sort', () => {
|
||||
it('counts likes per entry and shows liked_by_me for active profile', () => {
|
||||
const r1 = insertRecipe(db, recipe('R1'));
|
||||
const r2 = insertRecipe(db, recipe('R2'));
|
||||
const a = createProfile(db, 'A');
|
||||
const b = createProfile(db, 'B');
|
||||
const c = createProfile(db, 'C');
|
||||
addToWishlist(db, r1, a.id);
|
||||
addToWishlist(db, r2, a.id);
|
||||
likeWish(db, r1, a.id);
|
||||
likeWish(db, r1, b.id);
|
||||
likeWish(db, r1, c.id);
|
||||
likeWish(db, r2, a.id);
|
||||
|
||||
const listA = listWishlist(db, a.id, 'popular');
|
||||
expect(listA[0].title).toBe('R1');
|
||||
expect(listA[0].like_count).toBe(3);
|
||||
expect(listA[0].liked_by_me).toBe(1);
|
||||
expect(listA[1].title).toBe('R2');
|
||||
expect(listA[1].like_count).toBe(1);
|
||||
|
||||
const listB = listWishlist(db, b.id);
|
||||
expect(listB.find((e) => e.recipe_id === r1)!.liked_by_me).toBe(1);
|
||||
expect(listB.find((e) => e.recipe_id === r2)!.liked_by_me).toBe(0);
|
||||
});
|
||||
|
||||
it('unlike is idempotent and decrements count', () => {
|
||||
const r = insertRecipe(db, recipe('R'));
|
||||
const a = createProfile(db, 'A');
|
||||
addToWishlist(db, r, a.id);
|
||||
likeWish(db, r, a.id);
|
||||
unlikeWish(db, r, a.id);
|
||||
unlikeWish(db, r, a.id);
|
||||
const [entry] = listWishlist(db, a.id);
|
||||
expect(entry.like_count).toBe(0);
|
||||
expect(entry.liked_by_me).toBe(0);
|
||||
});
|
||||
|
||||
it('sort=newest orders by added_at desc, oldest asc', () => {
|
||||
const r1 = insertRecipe(db, recipe('First'));
|
||||
// Force different timestamps via raw insert with explicit added_at
|
||||
db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-01 10:00:00')").run(r1);
|
||||
const r2 = insertRecipe(db, recipe('Second'));
|
||||
db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-02 10:00:00')").run(r2);
|
||||
|
||||
expect(listWishlist(db, null, 'newest').map((e) => e.title)).toEqual(['Second', 'First']);
|
||||
expect(listWishlist(db, null, 'oldest').map((e) => e.title)).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
it('handles anonymous (no active profile) — liked_by_me always 0', () => {
|
||||
const r = insertRecipe(db, recipe('R'));
|
||||
addToWishlist(db, r, null);
|
||||
likeWish(db, r, createProfile(db, 'A').id);
|
||||
const [entry] = listWishlist(db, null);
|
||||
expect(entry.like_count).toBe(1);
|
||||
expect(entry.liked_by_me).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user