From b4fcbd7efc3ed9420f4873c46eed16f49a65ab65 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 11 Jun 2026 08:29:08 +0200 Subject: [PATCH] fix(geschichte): uniform onSubmit rejects-on-failure contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The c3afd57e fix made the edit page's handleSubmit throw on !res.ok but only JourneyEditor caught it. Now: GeschichteEditor.save() catches and keeps its dirty state (no unhandled rejection -> no GlitchTip noise on a failed STORY save); StoryCreate throws on failure so a failed STORY create no longer silently disarms the unsaved guard; both handleSubmit implementations catch network rejections, surface a message, and rethrow. Contract documented on both editors' Props. GeschichteEditor also gets the title maxlength=255. Spec: rejecting onSubmit is caught and the editor stays usable. Review round 3: Felix §2, Tobias S1, Nora (3), Markus (concern 1). Co-Authored-By: Claude Fable 5 --- .../lib/geschichte/GeschichteEditor.svelte | 20 ++++++++++++------- .../GeschichteEditor.svelte.spec.ts | 16 +++++++++++++++ .../routes/geschichten/[id]/edit/+page.svelte | 8 ++++++++ .../routes/geschichten/new/StoryCreate.svelte | 10 +++++++++- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte b/frontend/src/lib/geschichte/GeschichteEditor.svelte index b3b5a9b6..3a562427 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte @@ -14,6 +14,7 @@ type Person = components['schemas']['Person']; interface Props { geschichte?: GeschichteView | null; initialPersons?: Person[]; + /** Must reject when the save failed — the editor keeps its dirty state then. */ onSubmit: (payload: { title: string; body: string; @@ -106,13 +107,17 @@ function handleTitleInput() { async function save(nextStatus: 'DRAFT' | 'PUBLISHED') { titleTouched = true; if (titleEmpty) return; - await onSubmit({ - title: title.trim(), - body, - status: nextStatus, - personIds: selectedPersons.map((p) => p.id!).filter(Boolean) - }); - dirty = false; + try { + await onSubmit({ + title: title.trim(), + body, + status: nextStatus, + personIds: selectedPersons.map((p) => p.id!).filter(Boolean) + }); + dirty = false; + } catch { + // onSubmit signalled failure — keep dirty so the unsaved guard stays armed + } } function isActive(name: string, attrs?: Record): boolean { @@ -135,6 +140,7 @@ function exec(action: () => void) { { }); }); +describe('GeschichteEditor — onSubmit rejects on failure', () => { + it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => { + // Contract: onSubmit rejects on failure. Without the catch in save(), this + // click would surface as an unhandled promise rejection and fail the run. + const onSubmit = vi.fn().mockRejectedValue(new Error('save failed')); + render(GeschichteEditor, { geschichte: draftFactory(), onSubmit }); + + await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' })); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + // Editor still functional — a second save attempt goes through + await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' })); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + }); +}); + describe('GeschichteEditor — save bar adapts to status', () => { it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => { render(GeschichteEditor, { onSubmit: vi.fn() }); diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index 3b77c83c..59f4b8b1 100644 --- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte @@ -35,6 +35,14 @@ async function handleSubmit(payload: { throw new Error('save failed'); } goto(`/geschichten/${data.geschichte.id}`); + } catch (e) { + if (!errorMessage) { + console.error('Geschichte save failed', e); + errorMessage = getErrorMessage(undefined); + } + // Contract: onSubmit rejects on failure — both editors catch and keep + // their dirty state instead of disarming the unsaved-changes guard. + throw e; } finally { submitting = false; } diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte b/frontend/src/routes/geschichten/new/StoryCreate.svelte index 5bd49fdc..58d76083 100644 --- a/frontend/src/routes/geschichten/new/StoryCreate.svelte +++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte @@ -31,10 +31,18 @@ async function handleSubmit(payload: { if (!res.ok) { const code = (await res.json().catch(() => ({})))?.code; errorMessage = getErrorMessage(code); - return; + throw new Error('create failed'); } const created = await res.json(); goto(`/geschichten/${created.id}`); + } catch (e) { + if (!errorMessage) { + console.error('Story create failed', e); + errorMessage = getErrorMessage(undefined); + } + // Contract: onSubmit rejects on failure — GeschichteEditor catches and + // keeps its dirty state instead of disarming the unsaved-changes guard. + throw e; } finally { submitting = false; }