feat(wishlist): add shared family wishlist with likes
Each recipe appears at most once on the wishlist. Any profile can add,
remove, like, and unlike. Ratings and cooking log stay independent.
Data model: wishlist(recipe_id PK, added_by_profile_id, added_at)
wishlist_like(recipe_id, profile_id, created_at)
Why: 'das will ich essen' — family members pick candidates, everyone
can +1 to signal agreement, cook decides based on popularity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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