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'], + }, + }, ], }, });