diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index ecf4237f..c438b2db 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -1,11 +1,11 @@
-name: Quality (lint + typecheck)
+name: Quality (lint + typecheck + unit tests)
# Garde-fous statiques du frontend. Introduits en amont de l'upgrade Next 16
# pour rendre visibles les regressions de typage et de lint.
#
-# Etat actuel : NON BLOQUANT. Le depot demarre avec un passif d'erreurs de
-# typage et de lint ; les etapes sont donc en `continue-on-error` le temps de
-# resorber ce passif. Le passage en bloquant est le sujet de TASK-12.
+# Etat actuel : typage et lint NON BLOQUANTS. Le depot demarre avec un passif
+# d'erreurs ; ces deux etapes sont donc en `continue-on-error` le temps de
+# resorber ce passif. Les tests unitaires, eux, sont bloquants. Le passage en bloquant est le sujet de TASK-12.
on:
push:
@@ -39,3 +39,6 @@ jobs:
- name: Lint
continue-on-error: true
run: pnpm --filter frontend run lint
+
+ - name: Unit tests
+ run: pnpm --filter frontend run test:unit
diff --git a/frontend/README.md b/frontend/README.md
index fa3bb76d..6610d7be 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 +
@@ -101,20 +114,20 @@ Voici les étapes à suivre:
```typescript
export default async function Homepage() {
- const { data, error } = await client.GET('/home-page');
- if (error) {
- return
Error
;
- }
+ const result = await client.GET('/home-page');
+ const data = requireCmsData(result, 'singleton', '/home-page');
return (
Title:
- {data.data?.title}
+ {data.title}
);
}
```
+3. Passer le résultat par `requireCmsData` (`src/lib/cms-guard.ts`), jamais `data` directement : le client ne lève pas d'exception, il rend `{ data, error }`. La garde rend le contenu, répond 404 (`'detail'` sans entrée) ou lève une erreur journalisée `[cms] : ` qui produit un 500. Les pages dont seules les métadonnées viennent du CMS (charte, FAQ, CGU) restent tolérantes.
+
> [!NOTE]
> Avec le client fetch type-safe, les routes sont typées automatiquement ainsi que les paramètres et les données de réponse.
@@ -128,4 +141,4 @@ Rien pour le moment
- `fragile` : les redirections du CMS sont figées au `build` (`next.config.mjs`). L'image Docker de la CI est construite sans `STRAPI_API_TOKEN` → elle part sans aucune redirection, avec un simple avertissement.
- `not done` : `docker-compose.yml` vise une cible `frontend` absente du Dockerfile (étapes `base`, `builder`, `runner`) et fournit `STRAPI_URL` au lieu de `STRAPI_API_URL`.
-- `not done` : 141 erreurs TypeScript et 43 erreurs ESLint restent ouvertes ; `ignoreBuildErrors` est actif et le workflow `quality.yml` est en `continue-on-error`.
+- `not done` : 141 erreurs TypeScript et 41 erreurs ESLint restent ouvertes ; `ignoreBuildErrors` est actif et le workflow `quality.yml` est en `continue-on-error`.
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'],
+ },
+ },
],
},
});