From 92ee7f3420b32de3c47f0f0218e978b78fc4cd53 Mon Sep 17 00:00:00 2001 From: Legrand Thomas <18073293+DnzzL@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:24:56 +0200 Subject: [PATCH] fix(cms): rendre visible tout echec de chargement du CMS Le client openapi-fetch rend { data, error } sans jamais lever d'exception. Les pages ne lisaient que data, si bien qu'une requete en erreur et une requete sans contenu produisaient le meme rendu : une page blanche servie en 200, invisible pour les visiteurs comme pour les moteurs de recherche. Le tri se fait desormais en un seul endroit : - resolveCmsOutcome() decide entre rendre, repondre 404 et signaler une erreur ; fonction pure, couverte par 13 tests unitaires - requireCmsData() applique la decision, journalise l'adresse appelee avec le statut HTTP, puis rend le contenu ou interrompt le rendu - les 12 pages concernees y passent : une adresse de detail inconnue repond 404, une panne du CMS repond 500 avec une page d'erreur Les pages dont le contenu est statique et dont seules les metadonnees viennent du CMS (charte, FAQ, CGU) restent tolerantes : une metadonnee manquante ne justifie pas de faire echouer la page. Un projet vitest "unit" tourne en environnement Node, sans navigateur : les tests de logique pure ne dependent plus de Chromium et durent quelques centaines de millisecondes. Verifie en local : contenu identique sur les 16 URL de reference, adresses mortes en 404, jeton invalide entrainant 500 partout, 41 erreurs ESLint contre 43 auparavant, 250 tests verts. --- frontend/README.md | 13 ++++ frontend/messages/fr/error.json | 8 ++ frontend/package.json | 2 + frontend/src/app/[locale]/about/page.tsx | 11 +-- .../src/app/[locale]/blog/[slug]/page.tsx | 14 ++-- frontend/src/app/[locale]/blog/page.tsx | 9 ++- .../climate-and-biodiversity/page.tsx | 12 +-- frontend/src/app/[locale]/democracy/page.tsx | 12 +-- frontend/src/app/[locale]/donations/page.tsx | 12 ++- frontend/src/app/[locale]/error.tsx | 42 +++++++++++ frontend/src/app/[locale]/events/page.tsx | 9 ++- frontend/src/app/[locale]/page.tsx | 10 +-- frontend/src/app/[locale]/positions/page.tsx | 10 +-- .../src/app/[locale]/projects/[slug]/page.tsx | 11 +-- frontend/src/app/[locale]/projects/page.tsx | 10 +-- .../src/app/[locale]/social-justice/page.tsx | 12 +-- frontend/src/i18n/request.ts | 1 + frontend/src/lib/cms-guard.ts | 72 ++++++++++++++++++ frontend/src/lib/cms-outcome.test.ts | 75 +++++++++++++++++++ frontend/src/lib/cms-outcome.ts | 54 +++++++++++++ frontend/vitest.config.ts | 11 +++ 21 files changed, 338 insertions(+), 72 deletions(-) create mode 100644 frontend/messages/fr/error.json create mode 100644 frontend/src/app/[locale]/error.tsx create mode 100644 frontend/src/lib/cms-guard.ts create mode 100644 frontend/src/lib/cms-outcome.test.ts create mode 100644 frontend/src/lib/cms-outcome.ts diff --git a/frontend/README.md b/frontend/README.md index 0feb023d..1cd7bef0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -40,8 +40,21 @@ pnpm run build-storybook # Lancer les tests de composants (Vitest + navigateur, voir plus bas) pnpm run test pnpm run test:watch + +# Lancer les tests unitaires (Node, sans navigateur) +pnpm run test:unit + +# Lancer les deux +pnpm run test:all ``` +#### Tests unitaires + +`pnpm run test:unit` exécute les fichiers `src/**/*.test.ts` en environnement Node, sans navigateur : +quelques centaines de millisecondes, là où les tests de composants demandent une bonne minute. C'est +le bon endroit pour vérifier de la logique pure, par exemple la décision de rendu prise face à une +réponse Strapi (`src/lib/cms-outcome.ts`). + #### Tests de composants Les stories servent de tests : chaque story est rendue dans un vrai navigateur (Vitest + diff --git a/frontend/messages/fr/error.json b/frontend/messages/fr/error.json new file mode 100644 index 00000000..fc226ce1 --- /dev/null +++ b/frontend/messages/fr/error.json @@ -0,0 +1,8 @@ +{ + "error": { + "title": "Contenu momentanément indisponible", + "description": "Cette page n'a pas pu charger son contenu. La panne vient de notre côté, pas de votre navigateur.", + "retry": "Réessayer", + "backToHome": "Retour à l'accueil" + } +} diff --git a/frontend/package.json b/frontend/package.json index 65fe5923..d8daa720 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test": "vitest run --project storybook", + "test:unit": "vitest run --project unit", + "test:all": "vitest run", "test:watch": "vitest --project storybook", "typecheck": "tsc --noEmit" }, diff --git a/frontend/src/app/[locale]/about/page.tsx b/frontend/src/app/[locale]/about/page.tsx index 3d3a6d86..008b598a 100644 --- a/frontend/src/app/[locale]/about/page.tsx +++ b/frontend/src/app/[locale]/about/page.tsx @@ -1,4 +1,5 @@ import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; import AboutPage from './about'; @@ -74,12 +75,8 @@ export type AboutPageData = NonNullable(result, 'singleton', '/about'); - if (!data?.data) { - console.error(error); - return null; - } - - return ; + return ; }; diff --git a/frontend/src/app/[locale]/blog/[slug]/page.tsx b/frontend/src/app/[locale]/blog/[slug]/page.tsx index b014b495..12f33090 100644 --- a/frontend/src/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/src/app/[locale]/blog/[slug]/page.tsx @@ -1,5 +1,6 @@ import React from 'react'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import ArticlePage from './article'; import { getMarkdownContent } from '@/lib/markdown'; @@ -48,14 +49,9 @@ export async function generateMetadata( export default async function Page(props: { params: Promise<{ slug: string }> }) { const { slug } = await props.params; - const { data } = await fetchBlogPageData(slug); - - if (!data?.data || !data.data.length) { - return null; - } - - const blogPageData = data.data[0] as BlogPageData; - const contentHtml = await getMarkdownContent(blogPageData.content) + const result = await fetchBlogPageData(slug); + const [blogPageData] = requireCmsData(result, 'detail', `/blogs?slug=${slug}`); + const contentHtml = await getMarkdownContent(blogPageData.content); - return ; + return ; }; diff --git a/frontend/src/app/[locale]/blog/page.tsx b/frontend/src/app/[locale]/blog/page.tsx index 338eb218..cf43bfaa 100644 --- a/frontend/src/app/[locale]/blog/page.tsx +++ b/frontend/src/app/[locale]/blog/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import BlogPage from './blog'; import client from '@/lib/strapi-client'; +import { cmsUnavailable, requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -52,10 +53,12 @@ export type BlogsPageMeta = NonNullable[" export default async function Page() { const response = await fetchBlogsPageData(); + const data = requireCmsData(response, 'singleton', '/resources'); + const pagination = response.data?.meta?.pagination; - if (!response?.data || !response?.data?.meta?.pagination) { - return null; + if (!pagination) { + throw cmsUnavailable('/resources', 'pagination absente de la reponse'); } - return ; + return ; }; diff --git a/frontend/src/app/[locale]/climate-and-biodiversity/page.tsx b/frontend/src/app/[locale]/climate-and-biodiversity/page.tsx index a153eeb8..19d8a3c6 100644 --- a/frontend/src/app/[locale]/climate-and-biodiversity/page.tsx +++ b/frontend/src/app/[locale]/climate-and-biodiversity/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ClimatePage from './climate'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -92,12 +93,11 @@ export type ThematicPageData = NonNullable>["data"]>["data"]>; export default async function Page() { - const { data } = await fetchThematicPageData(); - const { data: thematicsData } = await fetchThematics(); + const themeResult = await fetchThematicPageData(); + const thematicsResult = await fetchThematics(); - if (!data?.data || !thematicsData?.data) { - return null; - } + const data = requireCmsData(themeResult, 'singleton', '/climate-and-biodiversity'); + const thematicsData = requireCmsData(thematicsResult, 'singleton', '/thematics'); - return ; + return ; }; diff --git a/frontend/src/app/[locale]/democracy/page.tsx b/frontend/src/app/[locale]/democracy/page.tsx index 189a25be..275caf1e 100644 --- a/frontend/src/app/[locale]/democracy/page.tsx +++ b/frontend/src/app/[locale]/democracy/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import DemocracyPage from './democracy'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -70,12 +71,11 @@ export type ThematicPageData = NonNullable>["data"]>["data"]>; export default async function Page() { - const { data } = await fetchThematicPageData(); - const { data: thematicsData } = await fetchThematics(); + const themeResult = await fetchThematicPageData(); + const thematicsResult = await fetchThematics(); - if (!data?.data || !thematicsData?.data) { - return null; - } + const data = requireCmsData(themeResult, 'singleton', '/democracy'); + const thematicsData = requireCmsData(thematicsResult, 'singleton', '/thematics'); - return ; + return ; }; diff --git a/frontend/src/app/[locale]/donations/page.tsx b/frontend/src/app/[locale]/donations/page.tsx index 458cead9..4ab3ac00 100644 --- a/frontend/src/app/[locale]/donations/page.tsx +++ b/frontend/src/app/[locale]/donations/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import DonationsPage from './donation'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; import { getMarkdownContent } from '@/lib/markdown'; @@ -48,12 +49,9 @@ async function fetchDonationData() { export type DonationsData = NonNullable>["data"]>["data"]>; export default async function Page() { - const { data } = await fetchDonationData(); - - if (!data?.data) { - return null; - } + const result = await fetchDonationData(); + const data = requireCmsData(result, 'singleton', '/donation'); - const introduction_text = await getMarkdownContent(data.data.introduction_text); - return ; + const introduction_text = await getMarkdownContent(data.introduction_text); + return ; }; diff --git a/frontend/src/app/[locale]/error.tsx b/frontend/src/app/[locale]/error.tsx new file mode 100644 index 00000000..e68b47da --- /dev/null +++ b/frontend/src/app/[locale]/error.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button, Title } from '@/components'; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations('error'); + + useEffect(() => { + // Le serveur a deja journalise la cause avec l'adresse appelee. Ici, c'est + // la console du navigateur qui parle, utile pendant le developpement. + console.error(error); + }, [error]); + + return ( +
+
+ + {t('title')} + +

+ {t('description')} +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/[locale]/events/page.tsx b/frontend/src/app/[locale]/events/page.tsx index b2c9a6e5..94dc4db3 100644 --- a/frontend/src/app/[locale]/events/page.tsx +++ b/frontend/src/app/[locale]/events/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import EventsPage from './events'; import client from '@/lib/strapi-client'; +import { cmsUnavailable, requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -52,10 +53,12 @@ export default async function Page( const pageSize = 6; const response = await fetchEventPageData(page, pageSize); + const data = requireCmsData(response, 'singleton', '/events'); + const pagination = response.data?.meta?.pagination; - if (!response?.data || !response?.data.meta?.pagination) { - return null; + if (!pagination) { + throw cmsUnavailable('/events', 'pagination absente de la reponse'); } - return ; + return ; }; diff --git a/frontend/src/app/[locale]/page.tsx b/frontend/src/app/[locale]/page.tsx index cd31df1e..0d379b3b 100644 --- a/frontend/src/app/[locale]/page.tsx +++ b/frontend/src/app/[locale]/page.tsx @@ -1,5 +1,6 @@ import Homepage from './home'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -73,11 +74,8 @@ async function fetchHomepageData() { export type HomepageData = NonNullable>["data"]>["data"]>; export default async function Page() { - const { data } = await fetchHomepageData(); + const result = await fetchHomepageData(); + const data = requireCmsData(result, 'singleton', '/home-page'); - if (!data?.data) { - return null; - } - - return ; + return ; } diff --git a/frontend/src/app/[locale]/positions/page.tsx b/frontend/src/app/[locale]/positions/page.tsx index c5d758b4..5d5c3bd7 100644 --- a/frontend/src/app/[locale]/positions/page.tsx +++ b/frontend/src/app/[locale]/positions/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import PositionsPage from './positions'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -80,11 +81,8 @@ async function fetchPositionPageData() { export type PositionsPageData = NonNullable>["data"]>["data"]>; export default async function Page() { - const { data } = await fetchPositionPageData(); - - if (!data?.data) { - return null; - } + const result = await fetchPositionPageData(); + const data = requireCmsData(result, 'singleton', '/position'); - return ; + return ; }; diff --git a/frontend/src/app/[locale]/projects/[slug]/page.tsx b/frontend/src/app/[locale]/projects/[slug]/page.tsx index fb0c0800..be0f07db 100644 --- a/frontend/src/app/[locale]/projects/[slug]/page.tsx +++ b/frontend/src/app/[locale]/projects/[slug]/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ProjectDetailPage from './projectDetail'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { getMarkdownContent } from '@/lib/markdown'; async function fetchProjectPageData(slug: string) { @@ -82,16 +83,12 @@ export async function generateMetadata( export default async function Page(props: { params: Promise<{ slug: string }> }) { const { slug } = await props.params; - const { data } = await fetchProjectPageData(slug); - - if (!data?.data || !data.data.length) { - return null; - } + const result = await fetchProjectPageData(slug); + const [projectData] = requireCmsData(result, 'detail', `/projects?slug=${slug}`); - const projectData = data.data[0] as ProjectPageData; const context = await getMarkdownContent(projectData.context); const long_description = await getMarkdownContent(projectData.long_description); const delivrable = await getMarkdownContent(projectData.delivrable); - return ; + return ; }; diff --git a/frontend/src/app/[locale]/projects/page.tsx b/frontend/src/app/[locale]/projects/page.tsx index 95f85035..2544a431 100644 --- a/frontend/src/app/[locale]/projects/page.tsx +++ b/frontend/src/app/[locale]/projects/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ProjectsPage from './projects'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -68,11 +69,8 @@ export type ProjectListPageData = NonNullable(result, 'singleton', '/projects-list'); - return ; + return ; }; diff --git a/frontend/src/app/[locale]/social-justice/page.tsx b/frontend/src/app/[locale]/social-justice/page.tsx index c43702bf..2f4684f9 100644 --- a/frontend/src/app/[locale]/social-justice/page.tsx +++ b/frontend/src/app/[locale]/social-justice/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import SocialPage from './social'; import client from '@/lib/strapi-client'; +import { requireCmsData } from '@/lib/cms-guard'; import { generateMetadataFromSeo } from '@/lib/utils'; export async function generateMetadata( @@ -70,12 +71,11 @@ export type ThematicPageData = NonNullable>["data"]>["data"]>; export default async function Page() { - const { data } = await fetchThematicPageData(); - const { data: thematicsData } = await fetchThematics(); + const themeResult = await fetchThematicPageData(); + const thematicsResult = await fetchThematics(); - if (!data?.data || !thematicsData?.data) { - return null; - } + const data = requireCmsData(themeResult, 'singleton', '/social-justice'); + const thematicsData = requireCmsData(thematicsResult, 'singleton', '/thematics'); - return ; + return ; }; diff --git a/frontend/src/i18n/request.ts b/frontend/src/i18n/request.ts index 7e4d9413..619d7a58 100644 --- a/frontend/src/i18n/request.ts +++ b/frontend/src/i18n/request.ts @@ -18,6 +18,7 @@ export default getRequestConfig(async ({ requestLocale }) => { ...(await import(`../../messages/${locale}/climate.json`)).default, ...(await import(`../../messages/${locale}/democracy.json`)).default, ...(await import(`../../messages/${locale}/donations.json`)).default, + ...(await import(`../../messages/${locale}/error.json`)).default, ...(await import(`../../messages/${locale}/events.json`)).default, ...(await import(`../../messages/${locale}/home.json`)).default, ...(await import(`../../messages/${locale}/layout.json`)).default, diff --git a/frontend/src/lib/cms-guard.ts b/frontend/src/lib/cms-guard.ts new file mode 100644 index 00000000..f9bf476b --- /dev/null +++ b/frontend/src/lib/cms-guard.ts @@ -0,0 +1,72 @@ +import { notFound } from 'next/navigation'; + +import type { CmsKind, CmsResult } from './cms-outcome'; +import { resolveCmsOutcome } from './cms-outcome'; + +/** + * Transforme un echec Strapi en erreur explicite, journalisee cote serveur. + * En production, les journaux du conteneur sont le seul canal disponible pour + * savoir quelle page a lache, et pourquoi. + */ +export function cmsUnavailable(endpoint: string, detail: unknown): Error { + const message = describeCmsError(detail); + console.error(`[cms] ${endpoint} : ${message}`); + + const error = new Error(`Contenu indisponible (${endpoint}) : ${message}`); + error.name = 'CmsUnavailableError'; + + return error; +} + +/** + * Garde commune des pages qui lisent le CMS. Rend le contenu, provoque un 404 + * si l'adresse ne correspond a aucune entree, ou leve une erreur si le CMS n'a + * pas repondu. La page ne peut plus s'afficher vide en 200 sans que personne + * ne s'en apercoive. + */ +export function requireCmsData(result: CmsResult, kind: CmsKind, endpoint: string): T { + const outcome = resolveCmsOutcome(result, kind); + + if (outcome === 'not-found') { + notFound(); + } + + if (outcome === 'error') { + throw cmsUnavailable(endpoint, result.error ?? 'reponse sans contenu'); + } + + return (result.data as { data: T }).data; +} + +/** Resume lisible d'une erreur openapi-fetch, qui peut etre un corps JSON ou une chaine. */ +function describeCmsError(detail: unknown): string { + if (detail === undefined || detail === null) { + return 'reponse sans contenu'; + } + + if (typeof detail === 'string') { + return detail; + } + + if (typeof detail === 'object') { + const body = detail as { error?: { message?: string; status?: number }; message?: string }; + const status = body.error?.status; + const parts = [ + typeof status === 'number' ? `HTTP ${status}` : undefined, + body.error?.message ?? body.message, + ].filter(Boolean); + + if (parts.length > 0) { + return parts.join(' '); + } + + try { + return JSON.stringify(detail).slice(0, 300); + } + catch { + return 'erreur non serialisable'; + } + } + + return String(detail); +} diff --git a/frontend/src/lib/cms-outcome.test.ts b/frontend/src/lib/cms-outcome.test.ts new file mode 100644 index 00000000..4cbeee2a --- /dev/null +++ b/frontend/src/lib/cms-outcome.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveCmsOutcome } from './cms-outcome'; + +describe('resolveCmsOutcome', () => { + describe('quand le CMS repond', () => { + it('demande le rendu sur une page singleton pourvue de contenu', () => { + expect(resolveCmsOutcome({ data: { data: { title: 'Accueil' } } }, 'singleton')).toBe('render'); + }); + + it('demande le rendu sur une page de detail dont l\'entree existe', () => { + expect(resolveCmsOutcome({ data: { data: [{ slug: '2-tonnes' }] } }, 'detail')).toBe('render'); + }); + + it('demande le rendu d\'une liste vide sur une page singleton', () => { + expect(resolveCmsOutcome({ data: { data: [] } }, 'singleton')).toBe('render'); + }); + + it('demande le rendu meme sans contenu imbrique, des lors que la reponse est la', () => { + expect(resolveCmsOutcome({ data: { data: false } }, 'singleton')).toBe('render'); + }); + }); + + describe('quand l\'adresse ne correspond a rien', () => { + it('renvoie vers un 404 sur une page de detail vide', () => { + expect(resolveCmsOutcome({ data: { data: [] } }, 'detail')).toBe('not-found'); + }); + + it('ne confond pas une liste vide et une panne, quel que soit l\'appelant', () => { + expect(resolveCmsOutcome({ data: { data: [] } }, 'detail')).not.toBe('error'); + }); + }); + + describe('quand le CMS ne repond pas', () => { + it('signale une erreur des que la reponse en porte une', () => { + const error = { error: { status: 400, message: 'Invalid key' } }; + + expect(resolveCmsOutcome({ data: { data: [] }, error }, 'detail')).toBe('error'); + }); + + it('donne la priorite a l\'erreur sur un contenu par ailleurs present', () => { + const error = { error: { status: 500 } }; + + expect(resolveCmsOutcome({ data: { data: { title: 'Accueil' } }, error }, 'singleton')).toBe('error'); + }); + + it('signale une erreur quand le contenu est explicitement nul', () => { + expect(resolveCmsOutcome({ data: { data: null } }, 'singleton')).toBe('error'); + }); + + it('signale une erreur quand l\'enveloppe de reponse est vide', () => { + expect(resolveCmsOutcome({ data: {} }, 'singleton')).toBe('error'); + }); + + it('signale une erreur quand il n\'y a aucune donnee du tout', () => { + expect(resolveCmsOutcome({}, 'singleton')).toBe('error'); + expect(resolveCmsOutcome({ data: undefined }, 'detail')).toBe('error'); + }); + + it('signale une erreur sur une enveloppe de forme inattendue', () => { + expect(resolveCmsOutcome({ data: 'reponse illisible' }, 'singleton')).toBe('error'); + expect(resolveCmsOutcome({ data: null }, 'singleton')).toBe('error'); + }); + + it('ne journalise rien de lui-meme, pour rester une fonction pure', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + resolveCmsOutcome({ error: { status: 403 } }, 'singleton'); + resolveCmsOutcome({ data: { data: [] } }, 'detail'); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + }); +}); diff --git a/frontend/src/lib/cms-outcome.ts b/frontend/src/lib/cms-outcome.ts new file mode 100644 index 00000000..0a933f3a --- /dev/null +++ b/frontend/src/lib/cms-outcome.ts @@ -0,0 +1,54 @@ +/** + * Decide ce qu'une page doit faire du resultat d'une requete Strapi. + * + * Le client openapi-fetch ne leve jamais d'exception : il rend `{ data, error }`. + * Les pages ne lisaient que `data`, si bien qu'une requete en erreur et une + * requete vide aboutissaient au meme rendu : une page blanche servie en 200, + * invisible pour les visiteurs comme pour les moteurs de recherche. + * + * Cette fonction rend la distinction explicite, et donc testable. + */ + +/** Forme minimale d'un resultat openapi-fetch. */ +export interface CmsResult { + data?: unknown; + error?: unknown; +} + +/** + * `singleton` : page dont le contenu est unique (l'accueil, /nous-connaitre). + * `detail` : page dont l'adresse designe une entree parmi d'autres + * (/projets/[slug], /ressources/[slug]). + */ +export type CmsKind = 'singleton' | 'detail'; + +export type CmsOutcome = 'render' | 'not-found' | 'error'; + +export function resolveCmsOutcome(result: CmsResult, kind: CmsKind = 'singleton'): CmsOutcome { + // 1. La requete a echoue : 400, 401, 403, 5xx. Strapi rejette la requete + // entiere des qu'un parametre est invalide, sans degradation partielle ; + // il n'y a donc rien a afficher, et le dire vaut mieux que de se taire. + if (result.error) { + return 'error'; + } + + const payload = (result.data as { data?: unknown } | null | undefined)?.data; + + // 2. La requete a reussi mais ne porte aucun contenu. Sur une page + // singleton, c'est le CMS qui est en cause : une erreur visible vaut mieux + // qu'une page vide que Google indexera comme valide. + if (payload === undefined || payload === null) { + return 'error'; + } + + // 3. Adresse de detail dont l'entree n'existe pas : c'est un 404, pas une + // panne. Confondre les deux transformerait chaque lien perime en 500 et + // ferait sortir ces pages de l'index. + if (kind === 'detail' && Array.isArray(payload) && payload.length === 0) { + return 'not-found'; + } + + // Un tableau vide sur une page singleton reste un contenu valide : la page + // s'affiche, simplement sans elements. + return 'render'; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 26756584..dea002af 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; @@ -40,6 +41,16 @@ export default defineConfig({ setupFiles: ['.storybook/vitest.setup.ts'], }, }, + { + // Tests unitaires purs : aucune dependance au navigateur, donc + // executables partout, y compris en CI, en quelques millisecondes. + extends: true, + test: { + name: 'unit', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }, ], }, });