From bc42f35f8c76ddf1dc610b1fc5758efee560abd5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:45:36 +0200 Subject: [PATCH] feat(client): PhotoUploadStore mit idle/loading/success/error --- src/lib/client/photo-upload.svelte.ts | 76 +++++++++++++++++++++++ tests/unit/photo-upload-store.test.ts | 87 +++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/lib/client/photo-upload.svelte.ts create mode 100644 tests/unit/photo-upload-store.test.ts diff --git a/src/lib/client/photo-upload.svelte.ts b/src/lib/client/photo-upload.svelte.ts new file mode 100644 index 0000000..aaf0f48 --- /dev/null +++ b/src/lib/client/photo-upload.svelte.ts @@ -0,0 +1,76 @@ +import type { Recipe } from '$lib/types'; + +export type UploadStatus = 'idle' | 'loading' | 'success' | 'error'; + +export class PhotoUploadStore { + status = $state('idle'); + recipe = $state(null); + errorCode = $state(null); + errorMessage = $state(null); + lastFile = $state(null); + + private controller: AbortController | null = null; + private readonly fetchImpl: typeof fetch; + + constructor(opts: { fetchImpl?: typeof fetch } = {}) { + this.fetchImpl = opts.fetchImpl ?? fetch; + } + + async upload(file: File): Promise { + this.lastFile = file; + await this.doUpload(file); + } + + async retry(): Promise { + if (this.lastFile) await this.doUpload(this.lastFile); + } + + reset(): void { + this.status = 'idle'; + this.recipe = null; + this.errorCode = null; + this.errorMessage = null; + this.lastFile = null; + this.controller?.abort(); + this.controller = null; + } + + abort(): void { + this.controller?.abort(); + } + + private async doUpload(file: File): Promise { + this.status = 'loading'; + this.recipe = null; + this.errorCode = null; + this.errorMessage = null; + this.controller = new AbortController(); + const fd = new FormData(); + fd.append('photo', file); + try { + const res = await this.fetchImpl('/api/recipes/extract-from-photo', { + method: 'POST', + body: fd, + signal: this.controller.signal + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + this.status = 'error'; + this.errorCode = typeof body.code === 'string' ? body.code : 'UNKNOWN'; + this.errorMessage = + typeof body.message === 'string' ? body.message : `HTTP ${res.status}`; + return; + } + this.recipe = body.recipe as Recipe; + this.status = 'success'; + } catch (e) { + if ((e as Error).name === 'AbortError') { + this.status = 'idle'; + return; + } + this.status = 'error'; + this.errorCode = 'NETWORK'; + this.errorMessage = (e as Error).message; + } + } +} diff --git a/tests/unit/photo-upload-store.test.ts b/tests/unit/photo-upload-store.test.ts new file mode 100644 index 0000000..3444034 --- /dev/null +++ b/tests/unit/photo-upload-store.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PhotoUploadStore } from '../../src/lib/client/photo-upload.svelte'; + +const validRecipe = { + id: null, + title: 'T', + description: 'D', + source_url: null, + source_domain: null, + image_path: null, + servings_default: null, + servings_unit: null, + prep_time_min: null, + cook_time_min: null, + total_time_min: null, + cuisine: null, + category: null, + ingredients: [], + steps: [{ position: 1, text: 'S' }], + tags: [] +}; + +function fakeFetch(responses: Response[]): typeof fetch { + let i = 0; + return vi.fn(async () => responses[i++]) as unknown as typeof fetch; +} + +function mkFile(): File { + return new File([new Uint8Array([1, 2, 3])], 'x.jpg', { type: 'image/jpeg' }); +} + +describe('PhotoUploadStore', () => { + it('starts in idle', () => { + const s = new PhotoUploadStore(); + expect(s.status).toBe('idle'); + }); + + it('transitions loading → success on happy path', async () => { + const s = new PhotoUploadStore({ + fetchImpl: fakeFetch([ + new Response(JSON.stringify({ recipe: validRecipe }), { status: 200 }) + ]) + }); + await s.upload(mkFile()); + expect(s.status).toBe('success'); + expect(s.recipe?.title).toBe('T'); + }); + + it('transitions to error with code on 422', async () => { + const s = new PhotoUploadStore({ + fetchImpl: fakeFetch([ + new Response( + JSON.stringify({ code: 'NO_RECIPE_IN_IMAGE', message: 'nope' }), + { status: 422 } + ) + ]) + }); + await s.upload(mkFile()); + expect(s.status).toBe('error'); + expect(s.errorCode).toBe('NO_RECIPE_IN_IMAGE'); + }); + + it('reset() brings store back to idle', async () => { + const s = new PhotoUploadStore({ + fetchImpl: fakeFetch([new Response('{"code":"X"}', { status: 503 })]) + }); + await s.upload(mkFile()); + expect(s.status).toBe('error'); + s.reset(); + expect(s.status).toBe('idle'); + expect(s.errorCode).toBeNull(); + expect(s.lastFile).toBeNull(); + }); + + it('retry re-uploads lastFile', async () => { + const s = new PhotoUploadStore({ + fetchImpl: fakeFetch([ + new Response('{"code":"X"}', { status: 503 }), + new Response(JSON.stringify({ recipe: validRecipe }), { status: 200 }) + ]) + }); + await s.upload(mkFile()); + expect(s.status).toBe('error'); + await s.retry(); + expect(s.status).toBe('success'); + }); +});