feat(geschichten): show blog writers' own drafts on the Geschichten overview (#807) #813
@@ -117,12 +117,17 @@ public class GeschichteService {
|
||||
*
|
||||
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||
* LazyInitializationException on the non-transactional list path.
|
||||
*
|
||||
* <p>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<GeschichteSummary> list(GeschichteStatus status, List<UUID> 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.
|
||||
|
||||
@@ -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<GeschichteSummary> 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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isDraft}
|
||||
<span
|
||||
data-testid="draft-badge"
|
||||
class="inline-flex items-center rounded-sm border border-line bg-canvas px-1.5 py-px font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>
|
||||
{m.geschichten_draft_badge()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content column -->
|
||||
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
||||
<!-- Compact meta line (mobile only) -->
|
||||
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
|
||||
<div class="mb-1 flex flex-wrap items-center gap-1.5 sm:hidden">
|
||||
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -57,6 +66,14 @@ const authorName = $derived(formatAuthorName(geschichte.author));
|
||||
style="background-color: {personAvatarColor(authorName)}"
|
||||
></span>
|
||||
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
|
||||
{#if isDraft}
|
||||
<span
|
||||
data-testid="draft-badge-mobile"
|
||||
class="inline-flex shrink-0 items-center rounded-sm border border-line bg-canvas px-1.5 py-px font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>
|
||||
{m.geschichten_draft_badge()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if publishedAt}
|
||||
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
|
||||
{/if}
|
||||
|
||||
@@ -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 <span>', 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');
|
||||
});
|
||||
});
|
||||
|
||||
37
frontend/src/lib/shared/server/settled.test.ts
Normal file
37
frontend/src/lib/shared/server/settled.test.ts
Normal file
@@ -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<unknown> = {
|
||||
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<unknown> = {
|
||||
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<unknown> = {
|
||||
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<unknown> = { status: 'fulfilled', value: null };
|
||||
expect(settled(res)).toBeNull();
|
||||
});
|
||||
});
|
||||
5
frontend/src/lib/shared/server/settled.ts
Normal file
5
frontend/src/lib/shared/server/settled.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function settled<T>(res: PromiseSettledResult<unknown> | 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;
|
||||
}
|
||||
@@ -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<T>(res: PromiseSettledResult<unknown> | 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.
|
||||
|
||||
@@ -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<GeschichteSummary[]>(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
|
||||
};
|
||||
|
||||
@@ -133,7 +133,32 @@ function removeDocument() {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rows -->
|
||||
<!-- Entwürfe section (blog writers only, unfiltered) -->
|
||||
{#if data.drafts.length > 0}
|
||||
<div class="border-b-2 border-line">
|
||||
<h2 class="px-3 pt-3 pb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichten_drafts_heading()}
|
||||
<span class="ml-1 font-normal text-ink-3 normal-case"
|
||||
>{m.geschichten_drafts_unfiltered_caption()}</span
|
||||
>
|
||||
</h2>
|
||||
<ul>
|
||||
{#each data.drafts as g (g.id)}
|
||||
<li class="border-b border-line-2 last:border-b-0">
|
||||
<GeschichteListRow geschichte={g} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Published rows -->
|
||||
{#if data.drafts.length > 0}
|
||||
<!-- Heading only when the Entwürfe section is present, to keep the h2 outline balanced -->
|
||||
<h2 class="px-3 pt-3 pb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichten_published_heading()}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if data.geschichten.length === 0}
|
||||
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
|
||||
{emptyMessage}
|
||||
|
||||
@@ -24,11 +24,18 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function person(id: string, displayName: string) {
|
||||
function makeData(overrides: Partial<PageData> = {}): 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: '<p>test</p>',
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user