feat(shopping): ShoppingCartStore (Client)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
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.
This commit is contained in:
52
src/lib/client/shopping-cart.svelte.ts
Normal file
52
src/lib/client/shopping-cart.svelte.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
type Snapshot = {
|
||||||
|
recipes: { recipe_id: number }[];
|
||||||
|
uncheckedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ShoppingCartStore {
|
||||||
|
uncheckedCount = $state(0);
|
||||||
|
recipeIds = $state<Set<number>>(new Set());
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
|
||||||
|
constructor(fetchImpl?: typeof fetch) {
|
||||||
|
this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a));
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
73
tests/unit/shopping-cart-store.test.ts
Normal file
73
tests/unit/shopping-cart-store.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user