From 1bd5dd106f46fae6bae3d59edc2dbf9384bb75f4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:31:29 +0200 Subject: [PATCH] feat(shopping): ShoppingCartStore (Client) Svelte-5-Runes-Store mit uncheckedCount, recipeIds und loaded. refresh() holt Snapshot via GET /api/shopping-list, addRecipe/ removeRecipe posten bzw. loeschen und refreshen anschliessend. Bei Netzwerkfehler bleibt der letzte bekannte State erhalten. --- src/lib/client/shopping-cart.svelte.ts | 52 ++++++++++++++++++ tests/unit/shopping-cart-store.test.ts | 73 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/lib/client/shopping-cart.svelte.ts create mode 100644 tests/unit/shopping-cart-store.test.ts diff --git a/src/lib/client/shopping-cart.svelte.ts b/src/lib/client/shopping-cart.svelte.ts new file mode 100644 index 0000000..e179c75 --- /dev/null +++ b/src/lib/client/shopping-cart.svelte.ts @@ -0,0 +1,52 @@ +type Snapshot = { + recipes: { recipe_id: number }[]; + uncheckedCount: number; +}; + +export class ShoppingCartStore { + uncheckedCount = $state(0); + recipeIds = $state>(new Set()); + loaded = $state(false); + + private readonly fetchImpl: typeof fetch; + + constructor(fetchImpl?: typeof fetch) { + this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a)); + } + + async refresh(): Promise { + try { + const res = await this.fetchImpl('/api/shopping-list'); + if (!res.ok) return; + const body = (await res.json()) as Snapshot; + this.recipeIds = new Set(body.recipes.map((r) => r.recipe_id)); + this.uncheckedCount = body.uncheckedCount; + this.loaded = true; + } catch { + // keep last known state on network error + } + } + + async addRecipe(recipeId: number): Promise { + const res = await this.fetchImpl('/api/shopping-list/recipe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ recipe_id: recipeId }) + }); + // Consume body to avoid leaking response, even if we ignore the payload. + await res.json().catch(() => null); + await this.refresh(); + } + + async removeRecipe(recipeId: number): Promise { + const res = await this.fetchImpl(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' }); + await res.json().catch(() => null); + await this.refresh(); + } + + isInCart(recipeId: number): boolean { + return this.recipeIds.has(recipeId); + } +} + +export const shoppingCartStore = new ShoppingCartStore(); diff --git a/tests/unit/shopping-cart-store.test.ts b/tests/unit/shopping-cart-store.test.ts new file mode 100644 index 0000000..45ae701 --- /dev/null +++ b/tests/unit/shopping-cart-store.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { ShoppingCartStore } from '../../src/lib/client/shopping-cart.svelte'; + +type FetchMock = ReturnType; + +function snapshotBody(opts: { + recipeIds?: number[]; + uncheckedCount?: number; +}) { + return { + recipes: (opts.recipeIds ?? []).map((id) => ({ + recipe_id: id, title: `R${id}`, image_path: null, servings: 4, servings_default: 4 + })), + rows: [], + uncheckedCount: opts.uncheckedCount ?? 0 + }; +} + +function makeFetch(responses: unknown[]): FetchMock { + const queue = [...responses]; + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => queue.shift() + } as Response)); +} + +describe('ShoppingCartStore', () => { + it('refresh populates recipeIds and uncheckedCount', async () => { + const fetchImpl = makeFetch([snapshotBody({ recipeIds: [1, 2], uncheckedCount: 3 })]); + const store = new ShoppingCartStore(fetchImpl); + await store.refresh(); + expect(store.uncheckedCount).toBe(3); + expect(store.isInCart(1)).toBe(true); + expect(store.isInCart(2)).toBe(true); + expect(store.isInCart(3)).toBe(false); + expect(store.loaded).toBe(true); + }); + + it('addRecipe posts then refreshes', async () => { + const fetchImpl = makeFetch([ + {}, + snapshotBody({ recipeIds: [42], uncheckedCount: 5 }) + ]); + const store = new ShoppingCartStore(fetchImpl); + await store.addRecipe(42); + expect(fetchImpl.mock.calls[0][0]).toBe('/api/shopping-list/recipe'); + expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'POST' }); + expect(store.isInCart(42)).toBe(true); + expect(store.uncheckedCount).toBe(5); + }); + + it('removeRecipe deletes then refreshes', async () => { + const fetchImpl = makeFetch([ + {}, + snapshotBody({ recipeIds: [], uncheckedCount: 0 }) + ]); + const store = new ShoppingCartStore(fetchImpl); + await store.removeRecipe(42); + expect(fetchImpl.mock.calls[0][0]).toBe('/api/shopping-list/recipe/42'); + expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); + expect(store.uncheckedCount).toBe(0); + }); + + it('refresh keeps last known state on network error', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('offline')); + const store = new ShoppingCartStore(fetchImpl); + store.uncheckedCount = 7; + await store.refresh(); + expect(store.uncheckedCount).toBe(7); + }); +});