feat(wishlist): per-user Wünsche + Header-Badge mit Gesamtzahl
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Schema-Änderung (Migration 005):
- Tabelle wishlist umgestellt auf PK (recipe_id, profile_id)
- wishlist_like-Tabelle zusammengelegt — Liken WAR schon "will ich auch",
also werden alle bestehenden Likes Memberships auf der neuen Tabelle.
- Alt-Einträge mit added_by_profile_id werden migriert, anonyme gehen
verloren (war inkonsistent, jetzt erzwingen wir profile_id NOT NULL).
Repository:
- listWishlist aggregiert pro Rezept: wanted_by_count, wanted_by_names
(kommagetrennt), on_my_wishlist für das aktive Profil
- listWishlistProfileIds(recipeId) für den Recipe-Page-Loader
- countWishlistRecipes für das Header-Badge (DISTINCT recipe_id)
- addToWishlist/removeFromWishlist/isOnMyWishlist alle mit profile_id
als Pflicht
API:
- POST /api/wishlist: profile_id jetzt Pflicht (nullable raus)
- DELETE /api/wishlist/[recipe_id]?profile_id=X (nur eigenes Entry)
- /api/wishlist/[recipe_id]/like komplett entfernt (Konzept obsolet)
- Neu: GET /api/wishlist/count → { count: <distinct recipes> }
UI:
- Header-Heart bekommt rotes Badge mit Zahl der Wunschliste-Rezepte.
wishlistStore in $lib/client/wishlist.svelte.ts hält den Count reaktiv;
Refresh auf Mount, nach Add/Remove, beim Öffnen der Wunschliste.
- Recipe-Detail: Loader liefert wishlist_profile_ids; onMyWishlist ist
ein $derived. Toggle fragt aktives Profil (alertAction sonst), mutiert
die lokale Liste + ruft wishlistStore.refresh.
- Wunschliste-Seite: Heart toggelt eigenen Wunsch, Count zeigt Gesamt-
wünsche, kommagetrennte Namen zeigen "wer will". Trash-Button
entfernt — Heart-off reicht jetzt.
Tests (99 → 99, 8 neu geschrieben):
- Per-User-Add/Remove, aggregierte Counts, on_my_wishlist, Cascades bei
Recipe/Profile-Delete, countWishlistRecipes = DISTINCT.
This commit is contained in:
@@ -7,9 +7,9 @@ import {
|
||||
addToWishlist,
|
||||
removeFromWishlist,
|
||||
listWishlist,
|
||||
isOnWishlist,
|
||||
likeWish,
|
||||
unlikeWish
|
||||
listWishlistProfileIds,
|
||||
isOnMyWishlist,
|
||||
countWishlistRecipes
|
||||
} from '../../src/lib/server/wishlist/repository';
|
||||
import type { Recipe } from '../../src/lib/types';
|
||||
|
||||
@@ -38,96 +38,95 @@ beforeEach(() => {
|
||||
db = openInMemoryForTest();
|
||||
});
|
||||
|
||||
describe('wishlist add/remove', () => {
|
||||
it('adds and lists', () => {
|
||||
describe('per-user wishlist', () => {
|
||||
it('adds and lists for a single profile', () => {
|
||||
const r1 = insertRecipe(db, recipe('Carbonara'));
|
||||
const p = createProfile(db, 'Hendrik');
|
||||
addToWishlist(db, r1, p.id);
|
||||
expect(isOnWishlist(db, r1)).toBe(true);
|
||||
expect(isOnMyWishlist(db, r1, p.id)).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');
|
||||
expect(list[0].wanted_by_count).toBe(1);
|
||||
expect(list[0].wanted_by_names).toBe('Hendrik');
|
||||
expect(list[0].on_my_wishlist).toBe(1);
|
||||
});
|
||||
|
||||
it('is idempotent on double-add', () => {
|
||||
it('aggregates multiple users per recipe', () => {
|
||||
const r1 = insertRecipe(db, recipe('Pizza'));
|
||||
const a = createProfile(db, 'Alice');
|
||||
const b = createProfile(db, 'Bob');
|
||||
const c = createProfile(db, 'Cara');
|
||||
addToWishlist(db, r1, a.id);
|
||||
addToWishlist(db, r1, b.id);
|
||||
addToWishlist(db, r1, c.id);
|
||||
|
||||
const listFromA = listWishlist(db, a.id);
|
||||
expect(listFromA.length).toBe(1);
|
||||
expect(listFromA[0].wanted_by_count).toBe(3);
|
||||
expect(listFromA[0].on_my_wishlist).toBe(1);
|
||||
|
||||
const ids = listWishlistProfileIds(db, r1);
|
||||
expect(ids.sort()).toEqual([a.id, b.id, c.id].sort());
|
||||
});
|
||||
|
||||
it('is idempotent on double-add for same profile', () => {
|
||||
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);
|
||||
const list = listWishlist(db, p.id);
|
||||
expect(list[0].wanted_by_count).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('removes only my entry, keeps others', () => {
|
||||
const r1 = insertRecipe(db, recipe('Salad'));
|
||||
const a = createProfile(db, 'A');
|
||||
const b = createProfile(db, 'B');
|
||||
addToWishlist(db, r1, a.id);
|
||||
addToWishlist(db, r1, b.id);
|
||||
removeFromWishlist(db, r1, a.id);
|
||||
expect(isOnMyWishlist(db, r1, a.id)).toBe(false);
|
||||
expect(isOnMyWishlist(db, r1, b.id)).toBe(true);
|
||||
expect(listWishlist(db, b.id)[0].wanted_by_count).toBe(1);
|
||||
});
|
||||
|
||||
it('cascades with recipe delete', () => {
|
||||
it('on_my_wishlist is 0 for profiles that did not wish', () => {
|
||||
const r1 = insertRecipe(db, recipe('Curry'));
|
||||
const a = createProfile(db, 'A');
|
||||
const b = createProfile(db, 'B');
|
||||
addToWishlist(db, r1, a.id);
|
||||
|
||||
const listFromB = listWishlist(db, b.id);
|
||||
expect(listFromB[0].on_my_wishlist).toBe(0);
|
||||
expect(listFromB[0].wanted_by_count).toBe(1);
|
||||
});
|
||||
|
||||
it('cascades when recipe is deleted', () => {
|
||||
const r1 = insertRecipe(db, recipe('X'));
|
||||
addToWishlist(db, r1, null);
|
||||
const a = createProfile(db, 'A');
|
||||
addToWishlist(db, r1, a.id);
|
||||
db.prepare('DELETE FROM recipe WHERE id = ?').run(r1);
|
||||
expect(listWishlist(db, a.id).length).toBe(0);
|
||||
});
|
||||
|
||||
it('cascades when profile is deleted', () => {
|
||||
const r1 = insertRecipe(db, recipe('X'));
|
||||
const a = createProfile(db, 'A');
|
||||
addToWishlist(db, r1, a.id);
|
||||
db.prepare('DELETE FROM profile WHERE id = ?').run(a.id);
|
||||
expect(listWishlist(db, null).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wishlist likes + sort', () => {
|
||||
it('counts likes per entry and shows liked_by_me for active profile', () => {
|
||||
it('countWishlistRecipes counts distinct recipes (not rows)', () => {
|
||||
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, r1, b.id); // same recipe, different user
|
||||
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);
|
||||
expect(countWishlistRecipes(db)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user