From 38a6d6b0fcbea21538114afcdab471e8ce3c793b Mon Sep 17 00:00:00 2001 From: marcel Date: Fri, 12 Jun 2026 19:46:03 +0200 Subject: [PATCH] feat(geschichten): show blog writers' own drafts on the Geschichten overview (#807) (#813) --- .../geschichte/GeschichteService.java | 9 ++- .../geschichte/GeschichteServiceTest.java | 45 ++++++++---- frontend/messages/de.json | 4 ++ frontend/messages/en.json | 4 ++ frontend/messages/es.json | 4 ++ .../lib/geschichte/GeschichteListRow.svelte | 21 +++++- .../GeschichteListRow.svelte.spec.ts | 30 ++++++++ .../src/lib/shared/server/settled.test.ts | 37 ++++++++++ frontend/src/lib/shared/server/settled.ts | 5 ++ frontend/src/routes/+page.server.ts | 7 +- .../src/routes/geschichten/+page.server.ts | 30 ++++++-- frontend/src/routes/geschichten/+page.svelte | 27 +++++++- .../routes/geschichten/page.server.test.ts | 68 ++++++++++++++++++- .../routes/geschichten/page.svelte.spec.ts | 60 ++++++++++++++++ 14 files changed, 316 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/shared/server/settled.test.ts create mode 100644 frontend/src/lib/shared/server/settled.ts diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index cbc8f6d1..ffc932e7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -117,12 +117,17 @@ public class GeschichteService { * *

Returns a {@link GeschichteSummary} projection — never carries items, preventing * LazyInitializationException on the non-transactional list path. + * + *

Security: {@code null} status always resolves to PUBLISHED — even for blog writers. + * Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts. + * This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts. */ public List list(GeschichteStatus status, List personIds, UUID documentId, int limit) { - GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; + boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT; + GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); - UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; + UUID authorId = isDraftRequest ? currentUser().getId() : null; // When personIds is empty, personCount=0 short-circuits the IN() predicate. // Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 2e87c50c..61962436 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.geschichte; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -228,21 +230,7 @@ class GeschichteServiceTest { geschichteService.list(null, List.of(), null, 50); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); - } - - @Test - void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() { - authenticateAs(writer, Permission.BLOG_WRITE); - GeschichteSummary s1 = mock(GeschichteSummary.class); - GeschichteSummary s2 = mock(GeschichteSummary.class); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) - .thenReturn(List.of(s1, s2)); - - List out = geschichteService.list(null, List.of(), null, 50); - - assertThat(out).hasSize(2); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); + verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); } @Test @@ -308,6 +296,33 @@ class GeschichteServiceTest { assertThat(out).hasSizeLessThanOrEqualTo(200); } + @Test + @DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts") + void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) + .thenReturn(List.of()); + + geschichteService.list(null, List.of(), null, 50); + + verify(geschichteRepository).findSummaries( + eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); + } + + @Test + @DisplayName("security: DRAFT status scopes to current user only") + void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) + .thenReturn(List.of()); + + geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); + + verify(geschichteRepository).findSummaries( + eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any()); + } + // ─── create ────────────────────────────────────────────────────────────── @Test diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 961597fe..bfc2d084 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1042,6 +1042,10 @@ "geschichten_filter_document_chip": "Gefiltert nach Brief:", "geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen", "geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief", + "geschichten_published_heading": "Veröffentlicht", + "geschichten_drafts_heading": "Entwürfe", + "geschichten_draft_badge": "Entwurf", + "geschichten_drafts_unfiltered_caption": "(alle Entwürfe)", "geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_published_on": "veröffentlicht am {date}", "journey_compiled_on": "zusammengestellt am {date}", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7d957e6a..2ec576aa 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1042,6 +1042,10 @@ "geschichten_filter_document_chip": "Filtered by letter:", "geschichten_filter_remove_document_chip": "Remove letter {title} from filter", "geschichten_empty_for_document": "No stories reference this letter yet", + "geschichten_published_heading": "Published", + "geschichten_drafts_heading": "Drafts", + "geschichten_draft_badge": "Draft", + "geschichten_drafts_unfiltered_caption": "(all drafts)", "geschichten_back_to_index": "Back to stories", "geschichten_published_on": "published on {date}", "journey_compiled_on": "compiled on {date}", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6ad45fcc..0bcd27ac 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1042,6 +1042,10 @@ "geschichten_filter_document_chip": "Filtrado por carta:", "geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro", "geschichten_empty_for_document": "Aún no hay historias sobre esta carta", + "geschichten_published_heading": "Publicadas", + "geschichten_drafts_heading": "Borradores", + "geschichten_draft_badge": "Borrador", + "geschichten_drafts_unfiltered_caption": "(todos los borradores)", "geschichten_back_to_index": "Volver a Historias", "geschichten_published_on": "publicada el {date}", "journey_compiled_on": "recopilada el {date}", diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte index 0138443b..f64a0cd1 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte @@ -7,12 +7,13 @@ import type { components } from '$lib/generated/api'; type GeschichteRow = Pick< components['schemas']['GeschichteSummary'], - 'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt' + 'id' | 'title' | 'body' | 'type' | 'status' | 'author' | 'publishedAt' >; let { geschichte }: { geschichte: GeschichteRow } = $props(); const isJourney = $derived(geschichte.type === 'JOURNEY'); +const isDraft = $derived(geschichte.status === 'DRAFT'); const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short')); @@ -44,12 +45,20 @@ const authorName = $derived(formatAuthorName(geschichte.author)); {m.journey_badge_list()} {/if} + {#if isDraft} + + {m.geschichten_draft_badge()} + + {/if}

-
+
{authorName} + {#if isDraft} + + {m.geschichten_draft_badge()} + + {/if} {#if publishedAt} {publishedAt} {/if} diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts index 52babd9c..4c430716 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -91,4 +91,34 @@ describe('GeschichteListRow', () => { render(GeschichteListRow, { props: { geschichte: baseRow() } }); expect(document.body.textContent).toContain('Anna Schmidt'); }); + + it('shows no draft badge for PUBLISHED stories', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'PUBLISHED' }) } }); + expect(document.querySelector('[data-testid="draft-badge"]')).toBeNull(); + expect(document.querySelector('[data-testid="draft-badge-mobile"]')).toBeNull(); + }); + + it('shows desktop draft badge for DRAFT stories', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge).not.toBeNull(); + }); + + it('shows mobile draft badge for DRAFT stories', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge-mobile"]'); + expect(badge).not.toBeNull(); + }); + + it('draft badge is a plain ', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge?.tagName.toLowerCase()).toBe('span'); + }); + + it('draft badge uses text-xs label size', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge!.className).toContain('text-xs'); + }); }); diff --git a/frontend/src/lib/shared/server/settled.test.ts b/frontend/src/lib/shared/server/settled.test.ts new file mode 100644 index 00000000..26ca88ec --- /dev/null +++ b/frontend/src/lib/shared/server/settled.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { settled } from './settled'; + +describe('settled', () => { + it('returns the data for a fulfilled ok response', () => { + const res: PromiseSettledResult = { + status: 'fulfilled', + value: { response: { ok: true } as Response, data: [{ id: '1' }] } + }; + expect(settled<{ id: string }[]>(res)).toEqual([{ id: '1' }]); + }); + + it('returns null for a fulfilled non-ok response', () => { + const res: PromiseSettledResult = { + status: 'fulfilled', + value: { response: { ok: false, status: 403 } as Response, data: undefined } + }; + expect(settled(res)).toBeNull(); + }); + + it('returns null for a rejected result', () => { + const res: PromiseSettledResult = { + status: 'rejected', + reason: new Error('network error') + }; + expect(settled(res)).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(settled(undefined)).toBeNull(); + }); + + it('returns null for a fulfilled null value (Promise.resolve(null) placeholder slot)', () => { + const res: PromiseSettledResult = { status: 'fulfilled', value: null }; + expect(settled(res)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/server/settled.ts b/frontend/src/lib/shared/server/settled.ts new file mode 100644 index 00000000..41dde16d --- /dev/null +++ b/frontend/src/lib/shared/server/settled.ts @@ -0,0 +1,5 @@ +export function settled(res: PromiseSettledResult | undefined): T | null { + if (res?.status !== 'fulfilled') return null; + const v = res.value as { response: Response; data: unknown } | null; + return v?.response?.ok ? ((v.data as T) ?? null) : null; +} diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index f1aea7d8..efcbeb18 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,6 +1,7 @@ import { redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/shared/api.server'; import type { components } from '$lib/generated/api'; +import { settled } from '$lib/shared/server/settled'; type StatsDTO = components['schemas']['StatsDTO']; type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; @@ -14,12 +15,6 @@ type DocumentListItem = components['schemas']['DocumentListItem']; type GeschichteSummary = components['schemas']['GeschichteSummary']; type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; -function settled(res: PromiseSettledResult | undefined): T | null { - if (res?.status !== 'fulfilled') return null; - const v = res.value as { response: Response; data: unknown }; - return v.response.ok ? ((v.data as T) ?? null) : null; -} - export async function load({ fetch, parent }) { const { canWrite, canAnnotate, canBlogWrite } = await parent(); // READ_ALL without WRITE_ALL or ANNOTATE_ALL — see ADR-007. diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index 76828641..d89a14b7 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -1,20 +1,23 @@ import { error } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; +import { settled } from '$lib/shared/server/settled'; import type { components } from '$lib/generated/api'; import type { PageServerLoad } from './$types'; type Person = components['schemas']['Person']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export const load: PageServerLoad = async ({ url, fetch }) => { +export const load: PageServerLoad = async ({ url, fetch, parent }) => { + const { canBlogWrite } = await parent(); const api = createApiClient(fetch); const personIds = url.searchParams.getAll('personId'); const rawDocumentId = url.searchParams.get('documentId'); const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null; - const [listResult, docResult, ...personResults] = await Promise.all([ + const [listSettled, draftsSettled, docSettled, ...personResults] = await Promise.allSettled([ api.GET('/api/geschichten', { params: { query: { @@ -24,20 +27,32 @@ export const load: PageServerLoad = async ({ url, fetch }) => { } } }), + canBlogWrite + ? api.GET('/api/geschichten', { params: { query: { status: 'DRAFT' } } }) + : Promise.resolve(null), documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : Promise.resolve(null), ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ]); - if (!listResult.response.ok) { - throw error(listResult.response.status, getErrorMessage(extractErrorCode(listResult.error))); + const listResult = listSettled.status === 'fulfilled' ? listSettled.value : null; + if (!listResult?.response.ok) { + throw error( + listResult?.response.status ?? 500, + getErrorMessage(extractErrorCode(listResult?.error)) + ); } - const personFilters = personResults - .filter((r) => r && r.response.ok && r.data) - .map((r) => r!.data!) as Person[]; + const drafts: GeschichteSummary[] = canBlogWrite + ? (settled(draftsSettled) ?? []) + : []; + const personFilters = personResults + .filter((r) => r.status === 'fulfilled' && r.value?.response.ok && r.value?.data) + .map((r) => (r as PromiseFulfilledResult<{ response: Response; data: Person }>).value.data); + + const docResult = docSettled.status === 'fulfilled' ? docSettled.value : null; let documentFilter: { id: string; title: string | null } | null = null; if (documentId) { if (docResult && docResult.response.ok && docResult.data) { @@ -53,6 +68,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => { return { geschichten: listResult.data ?? [], + drafts, personFilters, documentFilter }; diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index d14f950e..b1b43cb9 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -133,7 +133,32 @@ function removeDocument() {
{/if} - + + {#if data.drafts.length > 0} +
+

+ {m.geschichten_drafts_heading()} + {m.geschichten_drafts_unfiltered_caption()} +

+
    + {#each data.drafts as g (g.id)} +
  • + +
  • + {/each} +
+
+ {/if} + + + {#if data.drafts.length > 0} + +

+ {m.geschichten_published_heading()} +

+ {/if} {#if data.geschichten.length === 0}
{emptyMessage} diff --git a/frontend/src/routes/geschichten/page.server.test.ts b/frontend/src/routes/geschichten/page.server.test.ts index c9ce8b94..fd532424 100644 --- a/frontend/src/routes/geschichten/page.server.test.ts +++ b/frontend/src/routes/geschichten/page.server.test.ts @@ -24,11 +24,18 @@ function makeUrl(params: Record = {}) { return url; } -function callLoad(url: URL) { +function callLoad(url: URL, parentData: { canBlogWrite?: boolean } = {}) { return load({ url, request: new Request('http://localhost/geschichten'), - fetch: vi.fn() as unknown as typeof fetch + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ + canBlogWrite: false, + canWrite: false, + canAnnotate: false, + user: null, + ...parentData + }) }); } @@ -191,3 +198,60 @@ describe('geschichten page load — documentFilter title resolution', () => { ); }); }); + +// ─── draft fetch ────────────────────────────────────────────────────────────── + +describe('geschichten page load — draft fetch', () => { + it('makes a second API call for DRAFT stories when canBlogWrite is true', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl(), { canBlogWrite: true }); + + const draftCall = mockGet.mock.calls.find( + ([, opts]: [string, { params?: { query?: { status?: string } } }]) => + opts?.params?.query?.status === 'DRAFT' + ); + expect(draftCall).toBeDefined(); + }); + + it('does NOT make a DRAFT API call when canBlogWrite is false', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl(), { canBlogWrite: false }); + + const draftCall = mockGet.mock.calls.find( + ([, opts]: [string, { params?: { query?: { status?: string } } }]) => + opts?.params?.query?.status === 'DRAFT' + ); + expect(draftCall).toBeUndefined(); + }); + + it('returns empty drafts array when the DRAFT fetch rejects', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockRejectedValueOnce(new Error('network error')); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await callLoad(makeUrl(), { canBlogWrite: true }); + + expect(result.drafts).toEqual([]); + }); + + it('returns drafts in page data when canBlogWrite is true', async () => { + const draft = { id: 'draft-1', title: 'My Draft', status: 'DRAFT' }; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [draft] }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await callLoad(makeUrl(), { canBlogWrite: true }); + + expect(result.drafts).toEqual([draft]); + }); +}); diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index 410b1678..059eb589 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -26,6 +26,7 @@ function person(id: string, displayName: string) { function makeData(overrides: Partial = {}): PageData { return { geschichten: [], + drafts: [], personFilters: [], documentFilter: null, canBlogWrite: false, @@ -349,3 +350,62 @@ describe('geschichten page — multi-person filter chips', () => { expect(addEl.className).toContain('h-11'); }); }); + +// ─── Entwürfe section ───────────────────────────────────────────────────────── + +describe('geschichten page — Entwürfe section', () => { + const draft = () => + ({ + id: 'draft-1', + title: 'Mein Entwurf', + body: '

test

', + type: 'STORY', + status: 'DRAFT', + author: { firstName: 'Max', lastName: 'Muster' }, + publishedAt: null + }) as unknown as PageData['geschichten'][0]; + + it('Entwürfe section is hidden when drafts array is empty', async () => { + render(Page, { data: makeData({ drafts: [] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts') + ); + expect(heading).toBeUndefined(); + }); + + it('Entwürfe section is visible when drafts are present', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts') + ); + expect(heading).not.toBeUndefined(); + }); + + it('renders a row for each draft story', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const link = document.querySelector('a[href="/geschichten/draft-1"]'); + expect(link).not.toBeNull(); + }); + + it('draft row shows the draft badge', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge).not.toBeNull(); + }); + + it('Veröffentlicht heading is present when the Entwürfe section is visible', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Veröffentlicht') || h.textContent?.includes('Published') + ); + expect(heading).not.toBeUndefined(); + }); + + it('Veröffentlicht heading is absent when there are no drafts', async () => { + render(Page, { data: makeData({ drafts: [] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Veröffentlicht') || h.textContent?.includes('Published') + ); + expect(heading).toBeUndefined(); + }); +});