fix(geschichte): handle DELETE failure — show inline error on non-ok response

Adds deleteError $state to [id]/+page.svelte, parses backend error via
parseBackendError/getErrorMessage on !res.ok, and displays a role=alert
paragraph. Adds two browser-tier tests: success path (goto called) and
error path (alert visible, goto not called).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-09 08:03:04 +02:00
parent 994772564a
commit 7a5c2d0ba3
2 changed files with 84 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import { csrfFetch } from '$lib/shared/cookies';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import StoryReader from '$lib/geschichte/StoryReader.svelte';
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
@@ -24,7 +25,10 @@ const authorName = $derived(formatAuthorDisplayName(g.author));
const confirm = getConfirmService();
let deleteError = $state<string | null>(null);
async function handleDelete() {
deleteError = null;
const ok = await confirm.confirm({
title: m.geschichte_delete_confirm_title(),
body: m.geschichte_delete_confirm_body(),
@@ -36,6 +40,9 @@ async function handleDelete() {
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
if (res.ok) {
goto('/geschichten');
} else {
const err = await parseBackendError(res);
deleteError = getErrorMessage(err?.code);
}
}
</script>
@@ -65,6 +72,15 @@ async function handleDelete() {
</p>
</header>
{#if deleteError}
<p
role="alert"
class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger"
>
{deleteError}
</p>
{/if}
{#if isJourney}
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
{:else}

View File

@@ -1,8 +1,28 @@
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$lib/shared/cookies', () => ({
csrfFetch: vi.fn()
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { csrfFetch } from '$lib/shared/cookies';
import { goto } from '$app/navigation';
import type { components } from '$lib/generated/api';
const { default: GeschichtePage } = await import('./+page.svelte');
@@ -205,4 +225,50 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
});
it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => {
vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 }));
const confirmService = createConfirmService();
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, confirmService]]),
props: { data: baseData({ canBlogWrite: true }) }
});
// Trigger delete — opens confirm dialog
const deleteBtn = page.getByRole('button', { name: /löschen/i });
userEvent.click(deleteBtn);
// Settle the confirmation dialog
confirmService.settle(true);
// Wait for the async delete to complete, then check goto was called
await vi.waitFor(() => {
expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten');
});
});
it('delete failure: shows error message when DELETE returns non-ok', async () => {
vi.mocked(csrfFetch).mockResolvedValue(
new Response(JSON.stringify({ code: 'FORBIDDEN' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
);
const confirmService = createConfirmService();
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, confirmService]]),
props: { data: baseData({ canBlogWrite: true }) }
});
// Trigger delete — opens confirm dialog
const deleteBtn = page.getByRole('button', { name: /löschen/i });
userEvent.click(deleteBtn);
// Settle the confirmation dialog
confirmService.settle(true);
// Wait for the error to appear inline
await expect.element(page.getByRole('alert')).toBeVisible();
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
});