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:
@@ -5,6 +5,7 @@ import { formatDate } from '$lib/shared/utils/date';
|
|||||||
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
||||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
import StoryReader from '$lib/geschichte/StoryReader.svelte';
|
import StoryReader from '$lib/geschichte/StoryReader.svelte';
|
||||||
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
|
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
|
||||||
@@ -24,7 +25,10 @@ const authorName = $derived(formatAuthorDisplayName(g.author));
|
|||||||
|
|
||||||
const confirm = getConfirmService();
|
const confirm = getConfirmService();
|
||||||
|
|
||||||
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
deleteError = null;
|
||||||
const ok = await confirm.confirm({
|
const ok = await confirm.confirm({
|
||||||
title: m.geschichte_delete_confirm_title(),
|
title: m.geschichte_delete_confirm_title(),
|
||||||
body: m.geschichte_delete_confirm_body(),
|
body: m.geschichte_delete_confirm_body(),
|
||||||
@@ -36,6 +40,9 @@ async function handleDelete() {
|
|||||||
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
goto('/geschichten');
|
goto('/geschichten');
|
||||||
|
} else {
|
||||||
|
const err = await parseBackendError(res);
|
||||||
|
deleteError = getErrorMessage(err?.code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -65,6 +72,15 @@ async function handleDelete() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</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}
|
{#if isJourney}
|
||||||
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -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 { 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 { 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';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
const { default: GeschichtePage } = await import('./+page.svelte');
|
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(/Im Jahr 1923/)).toBeVisible();
|
||||||
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user