diff --git a/src/spa/cmsRoute.tsx b/src/spa/cmsRoute.tsx index 2eb102b..64fbc1d 100644 --- a/src/spa/cmsRoute.tsx +++ b/src/spa/cmsRoute.tsx @@ -7,7 +7,8 @@ import type { SpaApplicationPhaseData } from '@/spa/applicationPhase' import type { SpaNewsletterFormData } from '@/spa/newsletterForm' import type { SpaSiteSettingsData } from '@/spa/siteSettings' -export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder' +export type CmsRouteCollection = + 'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder' export type CmsRouteDoc = { id: number | string @@ -34,7 +35,13 @@ export type CmsRouteData = { const CmsRouteContext = createContext(undefined) -export function CmsRouteProvider({ children, value }: { children: React.ReactNode; value?: CmsRouteData }) { +export function CmsRouteProvider({ + children, + value, +}: { + children: React.ReactNode + value?: CmsRouteData +}) { return {children} } @@ -48,10 +55,17 @@ export function useCmsCollection(collection: CmsRouteCollection) { export function resolveCmsRelationship(entry: unknown, docs: CmsRouteDoc[]) { const entryID = entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : entry - const populatedDoc = docs.find((doc) => String(doc.id) === String(entryID)) + if (entryID === undefined || entryID === null) return undefined - if (populatedDoc) return populatedDoc - return entry && typeof entry === 'object' ? (entry as CmsRouteDoc) : undefined + return docs.find((doc) => String(doc.id) === String(entryID)) +} + +export function resolveCmsRelationships(entries: unknown, docs: CmsRouteDoc[]) { + if (!Array.isArray(entries)) return [] + + return entries + .map((entry) => resolveCmsRelationship(entry, docs)) + .filter((doc): doc is CmsRouteDoc => Boolean(doc)) } export function useApplicationPhase() { diff --git a/src/spa/homeWinnerSelection.ts b/src/spa/homeWinnerSelection.ts index e05284c..7e9b4c2 100644 --- a/src/spa/homeWinnerSelection.ts +++ b/src/spa/homeWinnerSelection.ts @@ -1,4 +1,4 @@ -import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute' +import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute' const FALLBACK_YEAR = 2025 const FALLBACK_LIMIT = 8 @@ -14,11 +14,8 @@ export function resolveHomeWinners( fallbackWinners: FallbackWinner[], ) { const selectedWinners = Array.isArray(featured) ? featured : [] - const selectedWinnerDocs = selectedWinners - .map((selected) => resolveCmsRelationship(selected, cmsWinners)) - .filter((winner): winner is CmsRouteDoc => Boolean(winner)) - if (selectedWinnerDocs.length) return selectedWinnerDocs + if (selectedWinners.length) return resolveCmsRelationships(selectedWinners, cmsWinners) return (cmsWinners.length ? cmsWinners : fallbackWinners) .filter((winner) => Number(winner.year) === FALLBACK_YEAR) diff --git a/src/spa/juryMembers.ts b/src/spa/juryMembers.ts index 32228d4..1fc50fe 100644 --- a/src/spa/juryMembers.ts +++ b/src/spa/juryMembers.ts @@ -1,4 +1,4 @@ -import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute' +import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute' const hasText = (value: unknown) => typeof value === 'string' && value.trim().length > 0 @@ -6,12 +6,7 @@ export function selectJuryDocs(selection: unknown, docs: CmsRouteDoc[]) { if (selection === undefined) return docs if (!Array.isArray(selection)) return [] - return selection - .map((entry) => resolveCmsRelationship(entry, docs)) - .filter( - (member): member is CmsRouteDoc => - Boolean(member && docs.some((doc) => String(doc.id) === String(member.id))), - ) + return resolveCmsRelationships(selection, docs) } export function sortJuryMembersQuoteFirst(members: T[]) { @@ -23,7 +18,8 @@ export function sortJuryMembersQuoteFirst(members: T[]) { const quoteOrder = Number(hasText(bSortable.quote)) - Number(hasText(aSortable.quote)) if (quoteOrder !== 0) return quoteOrder - const sortOrder = Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index) + const sortOrder = + Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index) return sortOrder !== 0 ? sortOrder : a.index - b.index }) .map(({ member }) => member) diff --git a/tests/int/cms-route-relationship.int.spec.ts b/tests/int/cms-route-relationship.int.spec.ts index cf117c9..0babdc4 100644 --- a/tests/int/cms-route-relationship.int.spec.ts +++ b/tests/int/cms-route-relationship.int.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute' +import { resolveCmsRelationship, resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute' const fullWinner: CmsRouteDoc = { id: 20, @@ -17,9 +17,24 @@ describe('CMS relationship resolution', () => { expect(resolveCmsRelationship(20, [fullWinner])).toBe(fullWinner) }) - it('preserves a populated relationship when no list document is available', () => { + it('rejects an embedded relationship when it is absent from the hydrated collection', () => { const relationship = { id: 20, image: { id: 55, url: '/api/media/file/image%20(1).png' } } - expect(resolveCmsRelationship(relationship, [])).toBe(relationship) + expect(resolveCmsRelationship(relationship, [])).toBeUndefined() + }) + + it('preserves relationship selection order while omitting unavailable documents', () => { + const first = { id: 1, title: 'First' } + const second = { id: 2, title: 'Second' } + const third = { id: 3, title: 'Third' } + + expect(resolveCmsRelationships([third, { id: 2, stale: true }, first], [first, third])).toEqual( + [third, first], + ) + expect(resolveCmsRelationships([third, second, first], [first, second, third])).toEqual([ + third, + second, + first, + ]) }) }) diff --git a/tests/int/home-winner-selection.int.spec.ts b/tests/int/home-winner-selection.int.spec.ts index 4cd53fb..c108736 100644 --- a/tests/int/home-winner-selection.int.spec.ts +++ b/tests/int/home-winner-selection.int.spec.ts @@ -18,7 +18,11 @@ const published2025Winners: CmsRouteDoc[] = Array.from({ length: 9 }, (_, index) describe('home winner selection', () => { it('uses a non-empty CMS selection without appending winners from the fallback year', () => { const featured = selected2026Winners.map(({ id }) => id) - const winners = resolveHomeWinners(featured, [...selected2026Winners, ...published2025Winners], []) + const winners = resolveHomeWinners( + featured, + [...selected2026Winners, ...published2025Winners], + [], + ) expect(winners).toEqual(selected2026Winners) expect(winners.some((winner) => winner.year === 2025)).toBe(false) @@ -29,4 +33,26 @@ describe('home winner selection', () => { expect(winners).toEqual(published2025Winners.slice(0, 8)) }) + + it('omits an unpublished selected winner without changing the remaining page order', () => { + const [first, unpublished, third] = selected2026Winners + const selection = [third, { ...unpublished, image: { id: 99 } }, first] + const publicWinners = resolveHomeWinners(selection, [first, third], published2025Winners) + const previewWinners = resolveHomeWinners( + selection, + [first, unpublished, third], + published2025Winners, + ) + + expect(publicWinners).toEqual([third, first]) + expect(previewWinners).toEqual([third, unpublished, first]) + }) + + it('does not replace an entirely unpublished CMS selection with fallback winners', () => { + const [unpublished] = selected2026Winners + + expect( + resolveHomeWinners([{ ...unpublished, image: { id: 99 } }], [], published2025Winners), + ).toEqual([]) + }) }) diff --git a/tests/int/jury-members.int.spec.ts b/tests/int/jury-members.int.spec.ts index b2fd670..1588f67 100644 --- a/tests/int/jury-members.int.spec.ts +++ b/tests/int/jury-members.int.spec.ts @@ -15,6 +15,14 @@ describe('Netzwerk jury members', () => { expect(selectJuryDocs(undefined, docs)).toEqual(docs) }) + it('omits an unpublished selected member publicly and restores it in preview order', () => { + const selection = [{ id: 3 }, { id: 2, name: 'Stale embedded member' }, { id: 1 }] + const publicDocs = [docs[0], docs[2]] + + expect(selectJuryDocs(selection, publicDocs).map((member) => member.id)).toEqual([3, 1]) + expect(selectJuryDocs(selection, docs).map((member) => member.id)).toEqual([3, 2, 1]) + }) + it('places non-empty quotes first and preserves sort order within each group', () => { const members = [ { name: 'Unquoted early', quote: '', sortOrder: 1 }, diff --git a/tests/int/public-route-hydration.int.spec.ts b/tests/int/public-route-hydration.int.spec.ts new file mode 100644 index 0000000..ee930fa --- /dev/null +++ b/tests/int/public-route-hydration.int.spec.ts @@ -0,0 +1,389 @@ +import config from '@/payload.config' +import { resolveHomeWinners } from '@/spa/homeWinnerSelection' +import { selectJuryDocs } from '@/spa/juryMembers' +import type { CmsRouteDoc } from '@/spa/cmsRoute' +import { getPublicationQueryOptions } from '@/utilities/publicationQuery' +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const lexicalDocument = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal' as const, + style: '', + text: 'Route hydration fixture', + type: 'text', + version: 1, + }, + ], + direction: 'ltr' as const, + format: '' as const, + indent: 0, + textFormat: 0, + type: 'paragraph', + version: 1, + }, + ], + direction: 'ltr' as const, + format: '' as const, + indent: 0, + type: 'root', + version: 1, + }, +} + +const fixtureKey = `route-hydration-${process.pid}-${Date.now()}` + +let payload: Payload +let mediaID: number +const cleanup: Array<() => Promise> = [] + +async function findFixtureDocs( + collection: 'events' | 'jury-mitglieder' | 'partners' | 'posts' | 'preistraeger', + ids: Array, + preview: boolean, + sort?: string, +): Promise { + const result = await payload.find({ + collection, + ...getPublicationQueryOptions(preview), + depth: 1, + limit: 20, + pagination: false, + ...(sort ? { sort } : {}), + where: { id: { in: ids } }, + }) + + return result.docs as unknown as CmsRouteDoc[] +} + +describe('public route hydration', () => { + beforeAll(async () => { + payload = await getPayload({ config }) + + const imageData = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + ) + const media = await payload.create({ + collection: 'media', + data: { alt: 'Route hydration fixture' }, + file: { + data: imageData, + mimetype: 'image/png', + name: `${fixtureKey}.png`, + size: imageData.length, + }, + }) + mediaID = media.id + }) + + afterAll(async () => { + for (const remove of cleanup.reverse()) await remove() + if (mediaID) await payload.delete({ collection: 'media', id: mediaID }) + }) + + it('omits an unpublished jury member selected on /netzwerk and restores it in preview', async () => { + const first = await payload.create({ + collection: 'jury-mitglieder', + context: { disableRevalidate: true }, + data: { _status: 'published', name: `${fixtureKey} jury first`, sortOrder: 10 }, + draft: false, + }) + const unpublished = await payload.create({ + collection: 'jury-mitglieder', + context: { disableRevalidate: true }, + data: { _status: 'draft', name: `${fixtureKey} jury unpublished`, sortOrder: 20 }, + draft: false, + }) + const third = await payload.create({ + collection: 'jury-mitglieder', + context: { disableRevalidate: true }, + data: { _status: 'published', name: `${fixtureKey} jury third`, sortOrder: 30 }, + draft: false, + }) + cleanup.push( + () => + payload.delete({ + collection: 'jury-mitglieder', + context: { disableRevalidate: true }, + id: first.id, + }), + () => + payload.delete({ + collection: 'jury-mitglieder', + context: { disableRevalidate: true }, + id: unpublished.id, + }), + () => + payload.delete({ + collection: 'jury-mitglieder', + context: { disableRevalidate: true }, + id: third.id, + }), + ) + + const ids = [first.id, unpublished.id, third.id] + const selection = [third, unpublished, first] + const publicDocs = await findFixtureDocs('jury-mitglieder', ids, false, 'sortOrder') + const previewDocs = await findFixtureDocs('jury-mitglieder', ids, true, 'sortOrder') + + expect(selectJuryDocs(selection, publicDocs).map((doc) => doc.id)).toEqual([third.id, first.id]) + expect(selectJuryDocs(selection, previewDocs).map((doc) => doc.id)).toEqual([ + third.id, + unpublished.id, + first.id, + ]) + }) + + it('omits an unpublished selected homepage winner and restores it after republishing without a page edit', async () => { + const first = await payload.create({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + data: { + _status: 'published', + slug: `${fixtureKey}-winner-first`, + title: `${fixtureKey} winner first`, + year: 2026, + }, + draft: false, + }) + const unpublished = await payload.create({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + data: { + _status: 'draft', + slug: `${fixtureKey}-winner-unpublished`, + title: `${fixtureKey} winner unpublished`, + year: 2026, + }, + draft: false, + }) + const third = await payload.create({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + data: { + _status: 'published', + slug: `${fixtureKey}-winner-third`, + title: `${fixtureKey} winner third`, + year: 2026, + }, + draft: false, + }) + cleanup.push( + () => + payload.delete({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + id: first.id, + }), + () => + payload.delete({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + id: unpublished.id, + }), + () => + payload.delete({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + id: third.id, + }), + ) + + const ids = [first.id, unpublished.id, third.id] + const selection = [third, unpublished, first] + const publicDocs = await findFixtureDocs('preistraeger', ids, false) + const previewDocs = await findFixtureDocs('preistraeger', ids, true) + + expect(resolveHomeWinners(selection, publicDocs, []).map((doc) => doc.id)).toEqual([ + third.id, + first.id, + ]) + expect(resolveHomeWinners(selection, previewDocs, []).map((doc) => doc.id)).toEqual([ + third.id, + unpublished.id, + first.id, + ]) + + await payload.update({ + collection: 'preistraeger', + context: { disableRevalidate: true }, + id: unpublished.id, + data: { _status: 'published' }, + draft: false, + }) + const republishedDocs = await findFixtureDocs('preistraeger', ids, false) + + expect(resolveHomeWinners(selection, republishedDocs, []).map((doc) => doc.id)).toEqual([ + third.id, + unpublished.id, + first.id, + ]) + }) + + it('omits an unpublished partner from the homepage ticker and preserves sort order', async () => { + const first = await payload.create({ + collection: 'partners', + context: { disableRevalidate: true }, + data: { + _status: 'published', + logo: mediaID, + name: `${fixtureKey} partner first`, + sortOrder: 10, + stableId: `${fixtureKey}-partner-first`, + tier: 1, + }, + draft: false, + }) + const unpublished = await payload.create({ + collection: 'partners', + context: { disableRevalidate: true }, + data: { + _status: 'draft', + logo: mediaID, + name: `${fixtureKey} partner unpublished`, + sortOrder: 20, + stableId: `${fixtureKey}-partner-unpublished`, + tier: 2, + }, + draft: false, + }) + const third = await payload.create({ + collection: 'partners', + context: { disableRevalidate: true }, + data: { + _status: 'published', + logo: mediaID, + name: `${fixtureKey} partner third`, + sortOrder: 30, + stableId: `${fixtureKey}-partner-third`, + tier: 3, + }, + draft: false, + }) + cleanup.push( + () => + payload.delete({ + collection: 'partners', + context: { disableRevalidate: true }, + id: first.id, + }), + () => + payload.delete({ + collection: 'partners', + context: { disableRevalidate: true }, + id: unpublished.id, + }), + () => + payload.delete({ + collection: 'partners', + context: { disableRevalidate: true }, + id: third.id, + }), + ) + + const ids = [first.id, unpublished.id, third.id] + const publicDocs = await findFixtureDocs('partners', ids, false, 'sortOrder') + const previewDocs = await findFixtureDocs('partners', ids, true, 'sortOrder') + + expect(publicDocs.map((doc) => doc.id)).toEqual([first.id, third.id]) + expect(previewDocs.map((doc) => doc.id)).toEqual([first.id, unpublished.id, third.id]) + }) + + it('omits an unpublished event previously rendered on /presse', async () => { + const published = await payload.create({ + collection: 'events', + context: { disableRevalidate: true }, + data: { + _status: 'published', + slug: `${fixtureKey}-event-published`, + title: `${fixtureKey} event published`, + }, + draft: false, + }) + const unpublished = await payload.create({ + collection: 'events', + context: { disableRevalidate: true }, + data: { + _status: 'draft', + slug: `${fixtureKey}-event-unpublished`, + title: `${fixtureKey} event unpublished`, + }, + draft: false, + }) + cleanup.push( + () => + payload.delete({ + collection: 'events', + context: { disableRevalidate: true }, + id: published.id, + }), + () => + payload.delete({ + collection: 'events', + context: { disableRevalidate: true }, + id: unpublished.id, + }), + ) + + const ids = [published.id, unpublished.id] + const publicDocs = await findFixtureDocs('events', ids, false) + const previewDocs = await findFixtureDocs('events', ids, true) + + expect(publicDocs.map((doc) => doc.id)).toEqual([published.id]) + expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort()) + }) + + it('omits an unpublished press article previously rendered on /presse', async () => { + const published = await payload.create({ + collection: 'posts', + context: { disableRevalidate: true }, + data: { + _status: 'published', + content: lexicalDocument, + slug: `${fixtureKey}-post-published`, + title: `${fixtureKey} post published`, + }, + draft: false, + }) + const unpublished = await payload.create({ + collection: 'posts', + context: { disableRevalidate: true }, + data: { + _status: 'draft', + content: lexicalDocument, + slug: `${fixtureKey}-post-unpublished`, + title: `${fixtureKey} post unpublished`, + }, + draft: false, + }) + cleanup.push( + () => + payload.delete({ + collection: 'posts', + context: { disableRevalidate: true }, + id: published.id, + }), + () => + payload.delete({ + collection: 'posts', + context: { disableRevalidate: true }, + id: unpublished.id, + }), + ) + + const ids = [published.id, unpublished.id] + const publicDocs = await findFixtureDocs('posts', ids, false) + const previewDocs = await findFixtureDocs('posts', ids, true) + + expect(publicDocs.map((doc) => doc.id)).toEqual([published.id]) + expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort()) + }) +})