feat: retire API publique + dedupe load-balancing MF + maj pages
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run

API publique retirée :
- /api/vigilance et /api/vigilance/dept/[code] supprimés
- Mentions retirées dans footer, /mentions-legales, /embed
- /api/health garde, sans CORS (usage interne UptimeRobot + cron HC.io)
- Tests E2E mis à jour (vérifient 404 sur les endpoints retirés)

Pages :
- /dependances : entièrement mise à jour (Sentry, sharp, Playwright,
  sitemap, typography ajoutés ; API MF officielle DPObs+DPVigilance,
  normales 1991-2020 listées ; section Services tiers ajoutée pour
  Opendatasoft en fallback ; section Infrastructure complétée avec
  GlitchTip + CrowdSec).
- /soutenir : "~7€/mois mutualisés" → "~30€/mois", suppression du
  détail VPS OVH (juste "infra"), suppression du bloc "Réutiliser
  les données via API".

Vigilance Météo France load-balancing :
- lib/vigilance.ts : fetch parallèle x3 et garde la réponse au
  update_time le plus récent. Constat 2026-05-26 : le gateway MF
  load-balance entre instances désynchronisées (~60% renvoyaient
  bulletin J-1, ~40% bulletin J). Cette mitigation atteint >95%
  de probabilité d'avoir le bulletin frais.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-26 02:16:04 +02:00
parent 0a1f11aa00
commit 27441cdbb8
10 changed files with 139 additions and 148 deletions

View file

@ -136,7 +136,6 @@ const jsonLd = {
<li><a href="/mentions-legales">Mentions légales</a></li> <li><a href="/mentions-legales">Mentions légales</a></li>
<li><a href="/dependances">Dépendances</a></li> <li><a href="/dependances">Dépendances</a></li>
<li><a href="/soutenir">☕ Soutenir sur Ko-fi</a></li> <li><a href="/soutenir">☕ Soutenir sur Ko-fi</a></li>
<li><a href="/api/vigilance">API JSON publique</a></li>
</ul> </ul>
</div> </div>
<div> <div>

View file

@ -80,12 +80,32 @@ async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
// --- Provider : API Météo France officielle (DPVigilance/v1) --- // --- Provider : API Météo France officielle (DPVigilance/v1) ---
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines // Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
// (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier). // (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier).
//
// PIÈGE : le gateway MF (WSO2) load-balance entre plusieurs instances
// qui peuvent être désynchronisées (constaté 2026-05-26 : ~60% des hits
// retournaient le bulletin J-1 à 4h UTC, ~40% le bulletin J à 14h UTC).
// Mitigation : on fait N fetches concurrents et on garde la réponse avec
// le `update_time` le plus récent. N=3 atteint >95% de prob d'avoir le frais.
const MF_PARALLEL_FETCHES = 3;
const MF_ENDPOINT = '/public/DPVigilance/v1/cartevigilance/encours';
async function fetchMFOnce(): Promise<{ update_time: string; raw: any } | null> {
const res = await fetchMF(MF_ENDPOINT);
if (!res.ok) return null;
const j = await res.json();
return { update_time: j?.product?.update_time ?? '', raw: j };
}
async function fetchMeteoFrance(): Promise<VigilanceSnapshot> { async function fetchMeteoFrance(): Promise<VigilanceSnapshot> {
const res = await fetchMF('/public/DPVigilance/v1/cartevigilance/encours'); const results = await Promise.all(Array.from({ length: MF_PARALLEL_FETCHES }, () => fetchMFOnce()));
if (!res.ok) { const valid = results.filter((r): r is { update_time: string; raw: any } => r !== null && !!r.update_time);
throw new Error(`MF cartevigilance failed: ${res.status} ${res.statusText}`); if (valid.length === 0) {
throw new Error('MF cartevigilance failed: all parallel fetches errored');
} }
const json = (await res.json()) as { // Garde le plus récent (update_time est ISO comparable lexicographiquement)
valid.sort((a, b) => (a.update_time < b.update_time ? 1 : -1));
const json = valid[0].raw as {
product: { product: {
update_time: string; update_time: string;
periods: Array<{ periods: Array<{

View file

@ -12,6 +12,11 @@ export const GET: APIRoute = async () => {
}; };
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status: cacheOk ? 200 : 503, status: cacheOk ? 200 : 503,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
// Pas de CORS : endpoint d'usage interne (UptimeRobot + cron HC.io freshness).
// Pas destiné aux clients tiers.
},
}); });
}; };

View file

@ -1,24 +0,0 @@
import type { APIRoute } from 'astro';
import { getVigilanceSnapshot } from '../../lib/vigilance';
export const prerender = false;
// JSON public du snapshot Vigilance actuel — réutilisable sous Licence Ouverte 2.0.
export const GET: APIRoute = async () => {
try {
const snap = await getVigilanceSnapshot();
return new Response(JSON.stringify(snap), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
},
});
} catch (e) {
return new Response(
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
);
}
};

View file

@ -1,62 +0,0 @@
import type { APIRoute } from 'astro';
import { getVigilanceSnapshot, alertsForDepartement } from '../../../../lib/vigilance';
import { getDepartement } from '../../../../lib/departements';
import { PHENOMENA, COLORS } from '../../../../lib/phenomena';
export const prerender = false;
// JSON par département (J + J1) — CORS *, réutilisable sous Licence Ouverte 2.0.
// Ex : GET /api/vigilance/dept/75 → alertes Paris
export const GET: APIRoute = async ({ params }) => {
const codeRaw = params.code;
if (!codeRaw) {
return new Response(JSON.stringify({ error: 'missing_code' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const code = codeRaw.toUpperCase();
const dept = getDepartement(code);
if (!dept) {
return new Response(JSON.stringify({ error: 'unknown_departement', code }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const snap = await getVigilanceSnapshot();
const today = alertsForDepartement(snap, code, 'J');
const tomorrow = alertsForDepartement(snap, code, 'J1');
const enrich = (a: ReturnType<typeof alertsForDepartement>[0]) => ({
phenomenonId: a.phenomenonId,
phenomenon: PHENOMENA[a.phenomenonId].label,
colorId: a.colorId,
color: COLORS[a.colorId].name,
beginTime: a.beginTime,
endTime: a.endTime,
});
const body = {
departement: { code, name: dept.name, region: dept.region },
productDatetime: snap.productDatetime,
fetchedAt: snap.fetchedAt,
today: today.map(enrich),
tomorrow: tomorrow.map(enrich),
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
},
});
} catch (e) {
return new Response(
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
);
}
};

View file

@ -11,10 +11,9 @@ const devDeps = Object.entries(pkg.devDependencies as Record<string, string>).so
a.localeCompare(b), a.localeCompare(b),
); );
// Catalogue manuel : description + license + URL pour chaque dépendance principale.
const META: Record<string, { desc: string; license: string; url: string }> = { const META: Record<string, { desc: string; license: string; url: string }> = {
astro: { astro: {
desc: "Framework web orienté contenu (rendu serveur, île d'interactivité).", desc: "Framework web orienté contenu (rendu serveur, îlots d'interactivité).",
license: 'MIT', license: 'MIT',
url: 'https://astro.build/', url: 'https://astro.build/',
}, },
@ -28,21 +27,46 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
license: 'MIT', license: 'MIT',
url: 'https://docs.astro.build/en/guides/integrations-guide/tailwind/', url: 'https://docs.astro.build/en/guides/integrations-guide/tailwind/',
}, },
'@astrojs/sitemap': {
desc: 'Génération automatique du sitemap-index.xml + sitemap-0.xml.',
license: 'MIT',
url: 'https://docs.astro.build/en/guides/integrations-guide/sitemap/',
},
'@sentry/astro': {
desc: "SDK Sentry/GlitchTip pour reporter les erreurs serveur (opt-in via env).",
license: 'MIT',
url: 'https://docs.sentry.io/platforms/javascript/guides/astro/',
},
'@tailwindcss/typography': {
desc: 'Plugin Tailwind pour le styling des pages de contenu (prose).',
license: 'MIT',
url: 'https://github.com/tailwindlabs/tailwindcss-typography',
},
tailwindcss: { tailwindcss: {
desc: 'Framework CSS utility-first.', desc: 'Framework CSS utility-first.',
license: 'MIT', license: 'MIT',
url: 'https://tailwindcss.com/', url: 'https://tailwindcss.com/',
}, },
ioredis: { ioredis: {
desc: 'Client Redis/Valkey performant pour Node.js (cache du snapshot Vigilance).', desc: 'Client Redis/Valkey performant pour Node.js (cache des snapshots).',
license: 'MIT', license: 'MIT',
url: 'https://github.com/redis/ioredis', url: 'https://github.com/redis/ioredis',
}, },
'@astrojs/check': { '@astrojs/check': {
desc: 'Type checker officiel Astro (utilisé en CI / dev).', desc: 'Type checker officiel Astro (vérification de types en CI / dev).',
license: 'MIT', license: 'MIT',
url: 'https://docs.astro.build/en/reference/cli-reference/#astro-check', url: 'https://docs.astro.build/en/reference/cli-reference/#astro-check',
}, },
'@playwright/test': {
desc: 'Framework de tests end-to-end (vérification du site live).',
license: 'Apache-2.0',
url: 'https://playwright.dev/',
},
sharp: {
desc: 'Génération de l\'image Open Graph (PNG depuis SVG) au build.',
license: 'Apache-2.0',
url: 'https://sharp.pixelplumbing.com/',
},
'@types/node': { '@types/node': {
desc: 'Types TypeScript pour les API Node.js.', desc: 'Types TypeScript pour les API Node.js.',
license: 'MIT', license: 'MIT',
@ -57,32 +81,49 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
const DATA_SOURCES = [ const DATA_SOURCES = [
{ {
name: 'France GeoJSON', name: 'Météo France — API Vigilance officielle (DPVigilance)',
desc: "Polygones des départements français (utilisés pour la carte SVG).", desc: "Bulletin Vigilance en temps réel, métropole, publication 2× par jour + complémentaires sur rouge.",
license: 'Licence Ouverte 2.0', license: 'Licence Ouverte 2.0',
url: 'https://github.com/gregoiredavid/france-geojson', url: 'https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance',
}, },
{ {
name: 'Météo France — Vigilance', name: 'Météo France — API Observation officielle (DPObs)',
desc: 'Bulletin Vigilance en temps réel via Opendatasoft.', desc: "Observations horaires SYNOP des stations principales (températures, vent, pression). Onglet 24 h sur les pages département.",
license: 'Licence Ouverte 2.0', license: 'Licence Ouverte 2.0',
url: 'https://public.opendatasoft.com/explore/dataset/weatherref-france-vigilance-meteo-departement/', url: 'https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesObservation',
}, },
{ {
name: 'Météo France — Climatologie', name: 'Météo France — Climatologie de base quotidienne',
desc: 'Données climatologiques de base quotidiennes (températures, précipitations).', desc: 'Données journalières TN/TX/TM/RR par station depuis 1950, agrégées par département. Onglets 7 j / 30 j / 1 an.',
license: 'Licence Ouverte 2.0', license: 'Licence Ouverte 2.0',
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes', url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
}, },
{
name: 'Normales 1991-2020',
desc: 'Normales saisonnières journalières TN/TX par département, pré-calculées sur la période WMO standard 1991-2020 (lissage 7 jours).',
license: 'Licence Ouverte 2.0 (dérivé des données ci-dessus)',
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
},
{
name: 'France GeoJSON',
desc: "Polygones des départements français (utilisés pour générer la carte SVG).",
license: 'Licence Ouverte 2.0',
url: 'https://github.com/gregoiredavid/france-geojson',
},
];
const SERVICES = [
{ name: 'Opendatasoft (fallback)', desc: "Source de secours pour la Vigilance si l'API Météo France officielle est indisponible.", license: 'LOv2', url: 'https://public.opendatasoft.com/explore/dataset/weatherref-france-vigilance-meteo-departement/' },
]; ];
const INFRA = [ const INFRA = [
{ name: 'Node.js 22', desc: "Runtime serveur.", license: 'MIT', url: 'https://nodejs.org/' }, { name: 'Node.js 22 LTS', desc: 'Runtime serveur.', license: 'MIT', url: 'https://nodejs.org/' },
{ name: 'Docker', desc: 'Containerisation.', license: 'Apache-2.0', url: 'https://www.docker.com/' }, { name: 'Docker', desc: 'Containerisation.', license: 'Apache-2.0', url: 'https://www.docker.com/' },
{ name: 'Caddy', desc: 'Reverse proxy + TLS automatique.', license: 'Apache-2.0', url: 'https://caddyserver.com/' }, { name: 'Caddy', desc: 'Reverse proxy + TLS automatique (Let\'s Encrypt).', license: 'Apache-2.0', url: 'https://caddyserver.com/' },
{ name: 'Valkey', desc: 'Fork ouvert de Redis (cache).', license: 'BSD-3-Clause', url: 'https://valkey.io/' }, { name: 'Valkey', desc: 'Fork ouvert de Redis (cache des snapshots).', license: 'BSD-3-Clause', url: 'https://valkey.io/' },
{ name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé).', license: 'MIT', url: 'https://umami.is/' }, { name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé, sans cookies).', license: 'MIT', url: 'https://umami.is/' },
{ name: 'Forgejo', desc: 'Forge git auto-hébergée + CI/CD.', license: 'MIT', url: 'https://forgejo.org/' }, { name: 'GlitchTip', desc: 'Error tracking compatible Sentry (auto-hébergé).', license: 'MIT', url: 'https://glitchtip.com/' },
{ name: 'CrowdSec', desc: 'Protection contre les attaques (rate-limit, ban des IPs malveillantes).', license: 'MIT', url: 'https://www.crowdsec.net/' },
]; ];
--- ---
@ -113,7 +154,31 @@ const INFRA = [
</thead> </thead>
<tbody> <tbody>
{DATA_SOURCES.map((s) => ( {DATA_SOURCES.map((s) => (
<tr class="border-t border-slate-200"> <tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-medium">
<a href={s.url} rel="noopener">{s.name}</a>
</td>
<td class="px-3 py-2 text-slate-700">{s.desc}</td>
<td class="px-3 py-2 text-slate-600">{s.license}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<h2 class="text-xl font-semibold">Services tiers</h2>
<table class="mt-3 w-full text-sm">
<thead class="bg-slate-100 text-left text-slate-700">
<tr>
<th class="px-3 py-2">Service</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2">Licence</th>
</tr>
</thead>
<tbody>
{SERVICES.map((s) => (
<tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-medium"> <td class="px-3 py-2 font-medium">
<a href={s.url} rel="noopener">{s.name}</a> <a href={s.url} rel="noopener">{s.name}</a>
</td> </td>
@ -140,7 +205,7 @@ const INFRA = [
{deps.map(([name, version]) => { {deps.map(([name, version]) => {
const m = META[name]; const m = META[name];
return ( return (
<tr class="border-t border-slate-200"> <tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-mono"> <td class="px-3 py-2 font-mono">
{m ? <a href={m.url} rel="noopener">{name}</a> : name} {m ? <a href={m.url} rel="noopener">{name}</a> : name}
</td> </td>
@ -161,6 +226,7 @@ const INFRA = [
<tr> <tr>
<th class="px-3 py-2">Package</th> <th class="px-3 py-2">Package</th>
<th class="px-3 py-2">Version</th> <th class="px-3 py-2">Version</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2">Licence</th> <th class="px-3 py-2">Licence</th>
</tr> </tr>
</thead> </thead>
@ -168,11 +234,12 @@ const INFRA = [
{devDeps.map(([name, version]) => { {devDeps.map(([name, version]) => {
const m = META[name]; const m = META[name];
return ( return (
<tr class="border-t border-slate-200"> <tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-mono"> <td class="px-3 py-2 font-mono">
{m ? <a href={m.url} rel="noopener">{name}</a> : name} {m ? <a href={m.url} rel="noopener">{name}</a> : name}
</td> </td>
<td class="px-3 py-2 text-slate-600">{version}</td> <td class="px-3 py-2 text-slate-600">{version}</td>
<td class="px-3 py-2 text-slate-700">{m?.desc ?? '—'}</td>
<td class="px-3 py-2 text-slate-600">{m?.license ?? '—'}</td> <td class="px-3 py-2 text-slate-600">{m?.license ?? '—'}</td>
</tr> </tr>
); );
@ -193,7 +260,7 @@ const INFRA = [
</thead> </thead>
<tbody> <tbody>
{INFRA.map((i) => ( {INFRA.map((i) => (
<tr class="border-t border-slate-200"> <tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-medium"> <td class="px-3 py-2 font-medium">
<a href={i.url} rel="noopener">{i.name}</a> <a href={i.url} rel="noopener">{i.name}</a>
</td> </td>

View file

@ -71,15 +71,13 @@ const snippet = `<iframe
</div> </div>
<div> <div>
<h2 class="text-xl font-semibold">API JSON brute (alternative)</h2> <h2 class="text-xl font-semibold">Besoin d'un accès programmatique ?</h2>
<p class="mt-2 text-sm text-slate-700"> <p class="mt-2 text-sm text-slate-700">
Si tu préfères afficher la Vigilance avec ton propre design, l'endpoint Ce site ne fournit pas d'API JSON publique. Si tu veux requêter la Vigilance Météo France
<code class="rounded bg-slate-100 px-1">/api/vigilance/dept/[code]</code> de manière programmatique, inscris-toi directement sur le
renvoie un JSON CORS *, prêt à parser : <a href="https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance" rel="noopener">portail API officiel Météo France</a>
(gratuit, ~5 min) — c'est la même source que celle utilisée par ce site.
</p> </p>
<pre class="mt-2 overflow-x-auto rounded bg-slate-900 p-4 text-xs text-slate-100"><code>fetch('{baseUrl}/api/vigilance/dept/{sample.code}')
.then(r =&gt; r.json())
.then(data =&gt; console.log(data.today)); // [&#123; phenomenon, color, ... &#125;]</code></pre>
</div> </div>
<div> <div>

View file

@ -112,8 +112,9 @@ export const prerender = false;
Météo France et la licence. Météo France et la licence.
</p> </p>
<p> <p>
L'endpoint <code>/api/vigilance</code> diffuse le snapshot courant en JSON (CORS *), pour Pour consulter la Vigilance Météo France de manière programmatique, utilisez directement
réutilisation par tout site ou application qui le souhaite. l'<a href="https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance" rel="noopener">API officielle Météo France</a>
(gratuite après inscription).
</p> </p>
</section> </section>
</Base> </Base>

View file

@ -13,7 +13,7 @@ export const prerender = false;
<h1 class="text-3xl font-bold sm:text-4xl">Soutenir Info Canicule</h1> <h1 class="text-3xl font-bold sm:text-4xl">Soutenir Info Canicule</h1>
<p class="mt-2 max-w-2xl text-slate-600"> <p class="mt-2 max-w-2xl text-slate-600">
Le site est gratuit, sans publicité et sans traceurs commerciaux. Si vous le trouvez utile, Le site est gratuit, sans publicité et sans traceurs commerciaux. Si vous le trouvez utile,
vous pouvez aider à couvrir l'hébergement (~7 €/mois mutualisés sur plusieurs projets perso). vous pouvez contribuer aux frais d'infrastructure (~30 €/mois).
</p> </p>
</div> </div>
</section> </section>
@ -38,7 +38,7 @@ export const prerender = false;
<h2>Ce que les dons financent</h2> <h2>Ce que les dons financent</h2>
<ul> <ul>
<li>Location du VPS OVH qui héberge le site (~7 €/mois, mutualisés avec d'autres projets perso).</li> <li>Frais d'infrastructure mensuels (~30 €/mois).</li>
<li>Nom de domaine annuel.</li> <li>Nom de domaine annuel.</li>
<li>Café pour les soirées de maintenance 🥲.</li> <li>Café pour les soirées de maintenance 🥲.</li>
</ul> </ul>
@ -59,10 +59,6 @@ export const prerender = false;
(proches âgés, personnes isolées) pour qui les conseils canicule peuvent faire une vraie (proches âgés, personnes isolées) pour qui les conseils canicule peuvent faire une vraie
différence. différence.
</li> </li>
<li>
<strong>Réutiliser les données</strong> : l'endpoint <code>/api/vigilance</code> diffuse le
snapshot en JSON (CORS *), réutilisable librement sous Licence Ouverte 2.0.
</li>
</ul> </ul>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">

View file

@ -3,35 +3,26 @@ import { test, expect } from '@playwright/test';
test.describe('API', () => { test.describe('API', () => {
test('/api/health retourne status ok et cache true', async ({ request }) => { test('/api/health retourne status ok et cache true', async ({ request }) => {
const res = await request.get('/api/health'); const res = await request.get('/api/health');
expect(res.status()).toBeLessThan(600); // accepte 200 ou 503 selon Valkey expect(res.status()).toBeLessThan(600);
const body = await res.json(); const body = await res.json();
expect(body).toHaveProperty('status'); expect(body).toHaveProperty('status');
expect(body).toHaveProperty('cache'); expect(body).toHaveProperty('cache');
expect(body).toHaveProperty('time'); expect(body).toHaveProperty('time');
}); });
test('/api/vigilance retourne snapshot JSON valide CORS *', async ({ request }) => { test('/api/health pas de CORS (interne uniquement)', async ({ request }) => {
const res = await request.get('/api/health');
const cors = res.headers()['access-control-allow-origin'];
expect(cors).toBeUndefined();
});
test('/api/vigilance retourne 404 (API publique retirée)', async ({ request }) => {
const res = await request.get('/api/vigilance'); const res = await request.get('/api/vigilance');
expect(res.status()).toBe(200); expect(res.status()).toBe(404);
expect(res.headers()['access-control-allow-origin']).toBe('*');
const body = await res.json();
expect(body).toHaveProperty('fetchedAt');
expect(body).toHaveProperty('alerts');
expect(Array.isArray(body.alerts)).toBe(true);
}); });
test('/api/vigilance/dept/13 retourne enrichi avec labels', async ({ request }) => { test('/api/vigilance/dept/75 retourne 404 (API publique retirée)', async ({ request }) => {
const res = await request.get('/api/vigilance/dept/13'); const res = await request.get('/api/vigilance/dept/75');
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.departement.code).toBe('13');
expect(body.departement.name).toBe('Bouches-du-Rhône');
expect(body).toHaveProperty('today');
expect(body).toHaveProperty('tomorrow');
});
test('/api/vigilance/dept/999 retourne 404', async ({ request }) => {
const res = await request.get('/api/vigilance/dept/999');
expect(res.status()).toBe(404); expect(res.status()).toBe(404);
}); });
}); });