feat(client): PhotoUploadStore mit idle/loading/success/error
This commit is contained in:
76
src/lib/client/photo-upload.svelte.ts
Normal file
76
src/lib/client/photo-upload.svelte.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Recipe } from '$lib/types';
|
||||||
|
|
||||||
|
export type UploadStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export class PhotoUploadStore {
|
||||||
|
status = $state<UploadStatus>('idle');
|
||||||
|
recipe = $state<Recipe | null>(null);
|
||||||
|
errorCode = $state<string | null>(null);
|
||||||
|
errorMessage = $state<string | null>(null);
|
||||||
|
lastFile = $state<File | null>(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<void> {
|
||||||
|
this.lastFile = file;
|
||||||
|
await this.doUpload(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async retry(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/unit/photo-upload-store.test.ts
Normal file
87
tests/unit/photo-upload-store.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user