Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1235 minIntermédiaire

API REST Node.js et MongoDB : CRUD, validation Zod et erreurs

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • connecter une API Express à MongoDB au démarrage ;
  • adapter l'API du cours Node.js à MongoDB en gardant ses middlewares ;
  • écrire des contrôleurs CRUD qui vérifient le propriétaire et l'état ;
  • traduire les erreurs de MongoDB en codes HTTP et tester l'API automatiquement.

3 exercices corrigés1 schéma

Brancher une API REST Express sur MongoDB consiste à remplacer sa couche d'accès aux données par des modèles Mongoose, à valider chaque requête avant qu'elle n'atteigne la base, et à traduire les erreurs de MongoDB en codes HTTP corrects. Ce chapitre reprend l'API d'AppliFrais du cours Node.js et Express (l'API REST CRUD), avec ses routes, sa validation Zod, son authentification par JWT, ses rôles et son gestionnaire d'erreurs, et la fait fonctionner sur MongoDB : connexion au démarrage, contrôleurs CRUD avec pagination, contrôle des propriétaires, erreurs propres à MongoDB et tests automatisés. Les principes de conception d'une API (ressources, codes de statut, format des erreurs) viennent du cours API REST.

L'architecture de l'API

PUT /v1/fiches/:id/lignes-forfaitexpress.json()authentifier : le jetonexigerRole : le rôlevalider : schéma Zodcontrôleurmodèle MongooseMongoDB400 : JSON invalide401 : jeton absent ou invalide403 : rôle insuffisant422 : données invalides404 : introuvable ; 409 : état409 : doublon E11000 ; 500réponse d'erreurproblem+jsonerreurs levées :middlewaregestionnaireErreurstout passe : le contrôleur répond200, 201 ou 204, en JSON
Chaque requête traverse les middlewares, puis le contrôleur et le modèle ; chaque erreur devient une réponse problem+json

L'API garde l'organisation en couches du cours Node.js, et la plupart de ses fichiers ne changent pas : passer de MySQL à MongoDB ne touche que la configuration, la connexion, l'accès aux données et la forme des identifiants.

Fichier du cours Node.jsAvec MongoDB
src/app.js, src/erreurs.js (ErreurApi, routeInconnue, gestionnaireErreurs)Inchangés, sauf une branche ajoutée au gestionnaire d'erreurs pour les erreurs de MongoDB
middlewares/valider.js, middlewares/autoriser.js (exigerRole)Inchangés
middlewares/authentifier.jsUne ligne change : l'identifiant n'est plus un nombre
src/config.jsMONGODB_URI remplace les variables DB_HOST, DB_USER, DB_PASSWORD, DB_NAME
src/db.jsLa connexion Mongoose remplace le pool mysql2
depots/ (les requêtes SQL)Remplacés par les modèles Visiteur et Fiche du chapitre 10
schemas/fiches.schemas.jsL'identifiant devient un ObjectId ; les autres schémas ne changent pas
controllers/fiches.controller.jsLes requêtes Mongoose

Dans ce chapitre, les contrôleurs appellent directement les modèles Mongoose, pour que tout le code MongoDB soit visible au même endroit. Dans ton projet, tu peux aussi garder les services du cours Node.js et ne réécrire que les dépôts : c'est tout l'intérêt d'une architecture en couches.

La route de connexion POST /v1/auth/connexion du cours Node.js reste la même ; seul son dépôt trouverParLogin devient une requête Mongoose. Ajoute au modèle Visiteur un champ login unique et l'empreinte motDePasse, déclarée avec select: false pour qu'elle ne sorte jamais par erreur, et cherche le compte avec Visiteur.findOne({ login }).select('+motDePasse'). N'ajoute pas lean() ici : le document Mongoose a une propriété id, la chaîne de l'ObjectId, que jwt.sign place telle quelle dans sub grâce à String(utilisateur.id) ; un objet lean() ne l'a pas, et le jeton contiendrait "undefined".

L'arborescence du projet :

text
applifrais-api/
├── .env                      # MONGODB_URI, JWT_SECRET, PORT (jamais versionné)
├── .env.example
├── .env.test                 # la configuration des tests (jamais versionnée)
├── package.json
├── src/
│   ├── server.js             # connexion à MongoDB, puis démarrage du serveur HTTP
│   ├── app.js                # l'application Express (cours Node.js, inchangé)
│   ├── config.js             # lit et vérifie les variables d'environnement
│   ├── db.js                 # la connexion Mongoose
│   ├── erreurs.js            # ErreurApi et gestionnaireErreurs (cours Node.js, une branche en plus)
│   ├── models/               # Visiteur.js, Fiche.js (chapitre 10)
│   ├── schemas/              # fiches.schemas.js : les schémas Zod des requêtes
│   ├── middlewares/          # authentifier.js, autoriser.js, valider.js (cours Node.js)
│   ├── controllers/          # fiches.controller.js
│   └── routes/               # fiches.routes.js
└── test/
    └── fiches.test.js

Express, Zod et jsonwebtoken sont déjà installés par le cours Node.js. On remplace mysql2 par Mongoose, et on ajoute de quoi tester avec une base jetable :

bash
npm uninstall mysql2
npm install mongoose
npm install --save-dev mongodb-memory-server

Se connecter au démarrage

L'application ne doit accepter des requêtes qu'une fois connectée à la base. Le module config.js du cours Node.js vérifie déjà les variables d'environnement au démarrage ; seule sa liste change :

js
// src/config.js (version MongoDB)
const obligatoires = ['MONGODB_URI', 'JWT_SECRET'];
const manquantes = obligatoires.filter((nom) => !process.env[nom]);
if (manquantes.length > 0) {
  throw new Error(`Variables d'environnement manquantes : ${manquantes.join(', ')}`);
}
if (process.env.JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET doit contenir au moins 32 caractères');
}

export const config = {
  env: process.env.NODE_ENV ?? 'development',
  port: Number(process.env.PORT ?? 3000),
  mongoUri: process.env.MONGODB_URI,
  jwtSecret: process.env.JWT_SECRET,
  // … les autres clés du cours Node.js (origineFront, dossierJustificatifs), inchangées
};
js
// src/db.js (remplace le pool MySQL)
import mongoose from 'mongoose';

export async function connecterBase(uri) {
  mongoose.set('sanitizeFilter', true);       // défense contre l'injection NoSQL (chapitre 13)
  mongoose.connection.on('disconnected', () => console.warn('MongoDB : connexion perdue'));
  await mongoose.connect(uri, { serverSelectionTimeoutMS: 5000 });
  console.log(`MongoDB : connecté à la base ${mongoose.connection.name}`);
}

Le serveur se connecte avant d'écouter. app.js, qui construit l'application sans ouvrir de port (les tests l'importent), ne change pas.

js
// src/server.js
import mongoose from 'mongoose';
import { app } from './app.js';
import { config } from './config.js';
import { connecterBase } from './db.js';

try {
  await connecterBase(config.mongoUri);
} catch (err) {
  console.error('Impossible de joindre MongoDB :', err.message);
  process.exit(1);
}

const serveur = app.listen(config.port, (erreur) => {
  if (erreur) throw erreur;
  console.log(`API AppliFrais à l'écoute sur http://localhost:${config.port}`);
});

// Arrêt propre (Ctrl+C, docker stop) : on termine les requêtes en cours, puis on ferme la connexion
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    serveur.close(async () => {
      await mongoose.disconnect();
      process.exit(0);
    });
  });
}

Si la base est injoignable au démarrage, le processus s'arrête au bout de cinq secondes (serverSelectionTimeoutMS) avec un message clair, au lieu de répondre 500 à chaque requête. En production, le gestionnaire de processus (Docker, systemd, PM2) le relance.

Valider les requêtes avec Zod

Mongoose valide les documents au moment de l'enregistrement. Ce n'est pas suffisant pour une API :

  • une requête de lecture (GET /v1/fiches?etat=…) ne crée aucun document : ses paramètres ne sont jamais validés par Mongoose ;
  • Mongoose ignore les champs inconnus au lieu de les refuser : un client qui envoie "etat": "VA" à la création ne reçoit aucun message, et rien ne te signale la tentative ;
  • un filtre construit avec une donnée non vérifiée peut contenir un opérateur ({ "$ne": null }) : c'est l'injection NoSQL du chapitre 13.

La validation avec Zod et le middleware valider sont ceux du cours Node.js et Express (valider les données avec Zod) : seuls changent les schémas, dont celui de l'identifiant, qui devient un ObjectId de 24 caractères hexadécimaux. Dans src/schemas/fiches.schemas.js, mois, quantite et les schémas des corps (schemaCreationFiche, schemaLignesForfait, schemaFraisHorsForfait, schemaConnexion…) restent identiques ; voici ce qui change ou s'ajoute :

js
// src/schemas/fiches.schemas.js : ce qui change pour MongoDB
import { ETATS } from '../models/Fiche.js';

// Un ObjectId écrit en chaîne : 24 caractères hexadécimaux, minuscules ou majuscules
const objectId = z.string().regex(/^[0-9a-fA-F]{24}$/, 'Identifiant invalide');

export const schemaId = z.object({ id: objectId });

export const schemaFiltresFiches = z.object({
  mois: mois.optional(),
  etat: z.enum(ETATS).optional(),        // les états du modèle Fiche, RB compris
  visiteur: objectId.optional(),
  page: z.coerce.number().int().min(1).default(1),
  limite: z.coerce.number().int().min(1).max(100).default(20),
});

export const schemaStatistiques = z.object({ mois });

Ce schéma schemaId rend la vérification d'un identifiant mal formé plus tôt et plus claire : sans lui, GET /v1/fiches/123 arriverait jusqu'à Mongoose, qui lèverait une CastError (« Cast to ObjectId failed »). Avec lui, le middleware valider répond 422 en désignant le champ id, et la base n'est jamais interrogée. Les contrôleurs ne lisent ensuite que req.valide, jamais req.body ou req.query bruts.

Les routes

js
// src/routes/fiches.routes.js
import { Router } from 'express';
import { authentifier } from '../middlewares/authentifier.js';
import { exigerRole } from '../middlewares/autoriser.js';
import { valider } from '../middlewares/valider.js';
import {
  schemaId, schemaFiltresFiches, schemaStatistiques, schemaCreationFiche, schemaLignesForfait,
} from '../schemas/fiches.schemas.js';
import * as fiches from '../controllers/fiches.controller.js';

export const routeurFiches = Router();
routeurFiches.use(authentifier); // toutes les routes exigent un jeton : req.utilisateur = { id, role }

routeurFiches.get('/', valider({ query: schemaFiltresFiches }), fiches.lister);
routeurFiches.get('/statistiques', exigerRole('comptable'), valider({ query: schemaStatistiques }), fiches.statistiques);
routeurFiches.get('/:id', valider({ params: schemaId }), fiches.lire);
routeurFiches.post('/', exigerRole('visiteur'), valider({ body: schemaCreationFiche }), fiches.creer);
routeurFiches.put('/:id/lignes-forfait', exigerRole('visiteur'),
  valider({ params: schemaId, body: schemaLignesForfait }), fiches.remplacerLignes);
routeurFiches.delete('/:id', exigerRole('visiteur'), valider({ params: schemaId }), fiches.supprimer);

/statistiques est déclarée avant /:id : sinon Express prendrait statistiques pour un identifiant, et valider le refuserait. Les routes communes aux deux versions gardent les corps de requête et les codes de statut du cours Node.js : un front qui appelait la version MySQL n'a presque rien à changer. Seules les réponses diffèrent, puisqu'elles renvoient les documents MongoDB (_id en ObjectId au lieu d'un id entier, le visiteur complété par populate).

Les contrôleurs

js
// src/controllers/fiches.controller.js
import { Fiche, FORFAITS } from '../models/Fiche.js';
import { ErreurApi } from '../erreurs.js';

const introuvable = (id) => new ErreurApi(404, 'fiche-introuvable', 'Fiche introuvable', `La fiche ${id} n'existe pas.`);

// Le filtre du propriétaire : un visiteur ne voit que ses fiches, le comptable les voit toutes
const filtreProprietaire = (utilisateur) =>
  utilisateur.role === 'comptable' ? {} : { visiteur: utilisateur.id };

export async function lister(req, res) {
  const { mois, etat, visiteur, page, limite } = req.valide.query;
  const filtre = {};
  if (mois) filtre.mois = mois;
  if (etat) filtre.etat = etat;
  if (visiteur) filtre.visiteur = visiteur;
  Object.assign(filtre, filtreProprietaire(req.utilisateur)); // en dernier : il écrase le visiteur demandé

  const [donnees, total] = await Promise.all([
    Fiche.find(filtre)
      .sort({ mois: -1, _id: 1 })
      .skip((page - 1) * limite)
      .limit(limite)
      .select('mois etat montantValide visiteur')
      .populate('visiteur', 'nom prenom')
      .lean(),
    Fiche.countDocuments(filtre),
  ]);
  res.json({ donnees, pagination: { page, limite, total, pages: Math.ceil(total / limite) } });
}

export async function lire(req, res) {
  const { id } = req.valide.params;
  const fiche = await Fiche.findOne({ _id: id, ...filtreProprietaire(req.utilisateur) })
    .populate('visiteur', 'nom prenom')
    .lean();
  if (!fiche) throw introuvable(id);
  res.json(fiche);
}

export async function creer(req, res) {
  const { mois } = req.valide.body;
  try {
    const fiche = await Fiche.create({ visiteur: req.utilisateur.id, mois });
    res.status(201).location(`/v1/fiches/${fiche._id}`).json(fiche);
  } catch (err) {
    if (err.code === 11000) { // l'index unique { visiteur, mois }
      throw new ErreurApi(409, 'fiche-existante', 'Fiche déjà créée', `Une fiche existe déjà pour le mois ${mois}.`);
    }
    throw err;
  }
}

export async function remplacerLignes(req, res) {
  const { id } = req.valide.params;
  // Le corps est { ETP, KM, NUI, REP } ; le document range un tableau de lignes.
  // Le prix unitaire vient du serveur, jamais du client.
  const lignesForfait = Object.entries(req.valide.body)
    .filter(([, quantite]) => quantite > 0)
    .map(([code, quantite]) => ({ code, quantite, montantUnitaire: FORFAITS[code] }));

  const fiche = await Fiche.findOneAndUpdate(
    { _id: id, visiteur: req.utilisateur.id, etat: 'CR' },
    { $set: { lignesForfait } },
    { returnDocument: 'after', runValidators: true },
  );
  if (!fiche) {
    const existe = await Fiche.exists({ _id: id, visiteur: req.utilisateur.id });
    if (!existe) throw introuvable(id);
    throw new ErreurApi(409, 'fiche-cloturee', 'Fiche non modifiable', 'Seule une fiche en cours (CR) se modifie.');
  }
  res.json(fiche);
}

export async function supprimer(req, res) {
  const { id } = req.valide.params;
  const { deletedCount } = await Fiche.deleteOne({ _id: id, visiteur: req.utilisateur.id, etat: 'CR' });
  if (deletedCount === 0) {
    const existe = await Fiche.exists({ _id: id, visiteur: req.utilisateur.id });
    if (!existe) throw introuvable(id);
    throw new ErreurApi(409, 'fiche-cloturee', 'Fiche non modifiable', 'Seule une fiche en cours se supprime.');
  }
  res.status(204).end();
}

export async function statistiques(req, res) {
  const stats = await Fiche.aggregate([
    { $match: { mois: req.valide.query.mois } },
    { $group: { _id: '$etat', nb: { $sum: 1 }, montantValide: { $sum: '$montantValide' } } },
    { $sort: { _id: 1 } },
    { $project: { _id: 0, etat: '$_id', nb: 1, montantValide: 1 } },
  ]);
  res.json(stats);
}

Six choix de ce code méritent d'être soulignés, car ce sont eux qu'on te demandera de justifier :

  1. Le propriétaire est dans le filtre. lire cherche { _id, visiteur: <l'utilisateur> } : la base ne renvoie que ce que le demandeur a le droit de voir. Une fiche qui appartient à un autre donne 404, comme une fiche inexistante, sans révéler qu'elle existe (contrôle d'accès).
  2. Le filtre du propriétaire passe en dernier. Dans lister, Object.assign l'applique après les filtres demandés : un visiteur qui envoie ?visiteur=<l'identifiant d'Inès> voit quand même ses propres fiches, et seulement elles. Le comptable, lui, peut filtrer sur n'importe quel visiteur.
  3. Les règles d'état sont dans le filtre. remplacerLignes et supprimer exigent etat: 'CR' dans le filtre : aucune requête simultanée ne peut modifier une fiche entre la vérification et l'écriture. La deuxième requête (exists) ne sert qu'à choisir le bon message, 404 ou 409.
  4. Le serveur fixe les montants. Le client n'envoie que des quantités ; le tarif vient de FORFAITS. Le corps { ETP, KM, NUI, REP } du cours Node.js est converti en tableau de lignes, la forme du document (chapitre 6) ; les quantités nulles ne créent pas de ligne.
  5. Le doublon est confié à l'index unique. creer ne vérifie pas d'abord si la fiche du mois existe : il tente l'insertion et traduit l'erreur E11000 en 409. Seul l'index unique protège contre deux requêtes simultanées.
  6. La pagination est plafonnée (100 au plus) et le tri est complet (mois, puis _id) : pas de page qui saute ou répète une fiche (pagination). Pour les lectures, lean() et populate donnent des objets simples, avec le nom du visiteur, en deux requêtes.

Traduire les erreurs de MongoDB en réponses HTTP

Le gestionnaire d'erreurs du cours Node.js traduit déjà les ErreurApi, le JSON invalide (400) et le corps trop gros (413) au format de la RFC 9457, et répond 500 à tout le reste. MongoDB y ajoute trois erreurs qui ne sont pas des pannes, mais des fautes du client ; sans traitement, elles finiraient en 500. On les traduit dans src/erreurs.js :

js
// src/erreurs.js : ajout pour MongoDB (ErreurApi et routeInconnue ne changent pas)
import mongoose from 'mongoose';

// Traduit une erreur de Mongoose ou de MongoDB en ErreurApi ; null si ce n'en est pas une.
export function erreurMongo(err) {
  if (err instanceof mongoose.Error.ValidationError) {
    const erreurs = Object.entries(err.errors).map(([champ, e]) => ({ champ, message: e.message }));
    return new ErreurApi(422, 'validation', 'Données invalides', 'Un ou plusieurs champs sont invalides.', { erreurs });
  }
  if (err instanceof mongoose.Error.CastError) {
    return new ErreurApi(400, 'parametre-invalide', 'Paramètre invalide', `Valeur invalide pour le champ ${err.path}.`);
  }
  if (err.code === 11000) {
    return new ErreurApi(409, 'doublon', 'Doublon', 'Cette ressource existe déjà.');
  }
  return null;
}

Dans gestionnaireErreurs, seule la dernière branche change : avant de conclure à une erreur imprévue, on demande à erreurMongo s'il la reconnaît.

js
// src/erreurs.js, dans gestionnaireErreurs
  } else if (!(err instanceof ErreurApi)) {
    erreur = erreurMongo(err);   // nouveau : ValidationError → 422, CastError → 400, E11000 → 409
    if (!erreur) {
      console.error(err);        // le détail pour toi, jamais pour le client
      erreur = new ErreurApi(500, 'erreur-interne', 'Erreur interne', 'Une erreur inattendue est survenue.');
    }
  }

Si tu as ajouté le journal pino et le numéro d'incident du cours Node.js, garde-les tels quels dans le bloc du 500.

ErreurD'où elle vientRéponse
ErreurApiUn contrôleur, valider ou exigerRole : introuvable, données invalides, interdit, état incompatibleSon propre code : 403, 404, 409, 422
ValidationErrorMongoose, si une donnée invalide a passé Zod422, champ par champ
CastErrorMongoose ne sait pas convertir une valeur (un identifiant non validé, par exemple)400
code === 11000Un index unique, si le contrôleur ne l'a pas déjà traduit409 Conflict
MongoServerSelectionErrorLa base est injoignable : arrêtée, réseau coupé500, message générique ; le détail reste dans le journal

L'API en action

Voici des échanges réels avec cette API sur le jeu de données (le jeton de Lucas Martin est dans la variable $JETON_LUCAS, celui de Sophie Lambert, comptable, dans $JETON_SOPHIE). Les tests manuels avec curl, Bruno ou Postman sont détaillés dans le cours API REST.

bash
curl -s "http://localhost:3000/v1/fiches?limite=2" -H "Authorization: Bearer $JETON_LUCAS"
json
{"donnees":[
  {"_id":"6ae8b983eb52ec336df67e95","visiteur":{"_id":"6a0000000000000000000001","nom":"Martin","prenom":"Lucas"},"mois":"2026-10","etat":"CR","montantValide":null},
  {"_id":"6ae8b983eb52ec336df67e94","visiteur":{"_id":"6a0000000000000000000001","nom":"Martin","prenom":"Lucas"},"mois":"2026-09","etat":"VA","montantValide":898.9}],
 "pagination":{"page":1,"limite":2,"total":3,"pages":2}}

Lucas ne voit que ses 3 fiches, par pages de 2. Une erreur a la forme commune à toute l'API :

json
{"type":"https://api.gsb.example/problemes/fiche-introuvable","title":"Fiche introuvable","status":404,"detail":"La fiche 6ae8b983eb52ec336df67e97 n'existe pas.","instance":"/v1/fiches/6ae8b983eb52ec336df67e97"}

Les autres cas (le type est abrégé : seule sa fin est indiquée) :

RequêteRéponse
GET /v1/fiches sans jeton401, non-authentifie, « Jeton absent. »
GET /v1/fiches?visiteur=<identifiant d'Inès> avec le jeton de Lucas200, les 3 fiches de Lucas : le filtre du propriétaire l'emporte
GET /v1/fiches?etat=XX&page=0422, validation, deux erreurs : etat (« Option invalide : une valeur parmi "CR"|"CL"|"VA"|"MP"|"RB" attendue ») et page (« Trop petit : nombre doit être >=1 »)
GET /v1/fiches/<fiche d'Inès> avec le jeton de Lucas404, fiche-introuvable
GET /v1/fiches/123422, {"champ":"id","message":"Identifiant invalide"}
POST /v1/fiches {"mois":"2026-10"} (la fiche d'octobre existe)409, fiche-existante, « Une fiche existe déjà pour le mois 2026-10. »
POST /v1/fiches {"mois":"2026-13"}422, {"champ":"mois","message":"Format attendu : AAAA-MM"}
POST /v1/fiches {"mois":"2026-11","etat":"VA","montantValide":5000}422, {"champ":"body","message":"Clés non reconnues : \"etat\", \"montantValide\""}
POST /v1/fiches {"mois":"2026-11",} (virgule finale)400, json-invalide
POST /v1/fiches {"mois":"2026-11"}201, en-tête Location: /v1/fiches/<id>, la fiche créée à l'état CR
PUT /v1/fiches/<id>/lignes-forfait {"ETP":0,"KM":230,"NUI":0,"REP":2,"montantUnitaire":1000}422, « Clé non reconnue : "montantUnitaire" »
PUT /v1/fiches/<id>/lignes-forfait {"ETP":0,"KM":230,"NUI":0,"REP":2}200, deux lignes (KM et REP) avec les tarifs du serveur (0,62 et 25)
PUT /v1/fiches/<fiche de septembre, validée>/lignes-forfait409, fiche-cloturee, « Seule une fiche en cours (CR) se modifie. »
POST /v1/fiches avec le jeton de Sophie403, acces-refuse
GET /v1/fiches/statistiques?mois=2026-09 avec le jeton de Sophie200 [{"nb":1,"montantValide":0,"etat":"CL"},{"nb":1,"montantValide":562.4,"etat":"MP"},{"nb":1,"montantValide":898.9,"etat":"VA"}]
DELETE /v1/fiches/<id> deux fois204, puis 404

Tester l'API automatiquement

Les tests d'intégration vérifient l'API complète, base comprise. Pour ne jamais toucher à ta base de développement, mongodb-memory-server lance un mongod jetable, en mémoire, le temps des tests (au premier lancement, il télécharge le binaire de MongoDB, ce qui prend une minute). Le test utilise le module node:test intégré à Node.js et fetch ; Supertest, utilisé dans le cours Node.js, convient tout aussi bien. Le cours Tests présente la démarche (tests d'intégration d'une API).

js
// test/fiches.test.js
import { test, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import jwt from 'jsonwebtoken';
import { app } from '../src/app.js';
import { Fiche } from '../src/models/Fiche.js';

let mongo, serveur, base;
const LUCAS = '6a0000000000000000000001';

// Un jeton signé comme ceux de la route de connexion : même clé, identifiant dans sub, rôle
const jetonDeTest = (role) =>
  jwt.sign({ role }, process.env.JWT_SECRET, { subject: LUCAS, expiresIn: '5m', algorithm: 'HS256' });

before(async () => {
  mongo = await MongoMemoryServer.create();          // un mongod jetable, en mémoire
  await mongoose.connect(mongo.getUri());
  await Fiche.init();                                 // crée les index, dont l'index unique
  serveur = app.listen(0);                            // un port libre, choisi par le système
  base = `http://localhost:${serveur.address().port}/v1/fiches`;
});

after(async () => {
  serveur.close();
  await mongoose.disconnect();
  await mongo.stop();
});

beforeEach(() => Fiche.deleteMany({}));               // chaque test part d'une collection vide

const requete = (methode, chemin, corps) =>
  fetch(base + chemin, {
    method: methode,
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jetonDeTest('visiteur')}` },
    body: corps && JSON.stringify(corps),
  });

test('crée une fiche, puis refuse un doublon pour le même mois', async () => {
  assert.equal((await requete('POST', '', { mois: '2026-11' })).status, 201);
  assert.equal((await requete('POST', '', { mois: '2026-11' })).status, 409);
});

test('refuse un champ non prévu (affectation de masse)', async () => {
  const r = await requete('POST', '', { mois: '2026-11', etat: 'VA' });
  assert.equal(r.status, 422);
  assert.equal(await Fiche.countDocuments(), 0);
});

test('le tarif vient du serveur, pas du client', async () => {
  const { _id } = await (await requete('POST', '', { mois: '2026-11' })).json();
  const r = await requete('PUT', `/${_id}/lignes-forfait`, { ETP: 0, KM: 0, NUI: 0, REP: 2 });
  assert.equal(r.status, 200);
  assert.deepEqual((await r.json()).lignesForfait, [{ code: 'REP', quantite: 2, montantUnitaire: 25 }]);
});

jetonDeTest(role) signe un JWT avec la clé de test, avec les mêmes champs (sub et role) que les jetons de la route de connexion : il passe donc par le vrai middleware authentifier. Le script test du cours Node.js (node --env-file=.env.test --test …) charge un fichier .env.test qui contient un JWT_SECRET réservé aux tests (32 caractères au moins) et une MONGODB_URI, exigée par config.js mais inutilisée : les tests se connectent au serveur jetable. npm test lance les trois tests en trois secondes environ, une fois le binaire téléchargé. Dans un pipeline d'intégration continue, le même npm test tourne à chaque push (cours d'intégration continue).

Exercices

Exercice 1 sur 3 Ajouter une route de clôture Réussi À revoir

Ajoute la route PATCH /v1/fiches/:id/cloture, qui permet à un visiteur de clôturer sa fiche si elle est en cours et qu'elle contient au moins une ligne de frais forfaitisés. Réponses attendues : 200 avec la fiche clôturée, 404 si la fiche n'existe pas ou n'est pas à lui, 409 si elle n'est pas en cours ou qu'elle est vide.

Voir la réponseMasquer la réponse de l'exercice 1
js
// routes
routeurFiches.patch('/:id/cloture', exigerRole('visiteur'), valider({ params: schemaId }), fiches.cloturer);

// contrôleur (ajoute import mongoose from 'mongoose' en tête du fichier)
export async function cloturer(req, res) {
  const { id } = req.valide.params;
  const fiche = await Fiche.findOneAndUpdate(
    { _id: id, visiteur: req.utilisateur.id, etat: 'CR',
      'lignesForfait.0': mongoose.trusted({ $exists: true }) },
    { $set: { etat: 'CL' } },
    { returnDocument: 'after', runValidators: true },
  );
  if (fiche) return res.json(fiche);

  const existante = await Fiche.findOne({ _id: id, visiteur: req.utilisateur.id }).select('etat').lean();
  if (!existante) throw introuvable(id);
  if (existante.etat !== 'CR') {
    throw new ErreurApi(409, 'fiche-cloturee', 'Fiche non modifiable', `La fiche est à l'état ${existante.etat}.`);
  }
  throw new ErreurApi(409, 'fiche-vide', 'Fiche vide', 'Ajoute au moins une ligne de frais avant de clôturer.');
}

Toutes les conditions sont dans le filtre de findOneAndUpdate : l'opération est atomique. 'lignesForfait.0' désigne le premier élément du tableau : s'il existe, la fiche a au moins une ligne. mongoose.trusted est indispensable ici, parce que l'API active sanitizeFilter : sans lui, { $exists: true } serait neutralisé, le filtre ne trouverait jamais rien, et toutes les clôtures répondraient 409, sans aucune erreur pour t'alerter. La lecture qui suit ne sert qu'à choisir le bon message d'erreur. Sur le jeu de données, la fiche d'octobre de Lucas (deux lignes) passe à CL ; celle de Camille Roux, vide, donne 409 « Fiche vide ».

Tu avais trouvé ?

Exercice 2 sur 3 Ajouter un frais hors forfait Réussi À revoir

Écris le contrôleur de la route POST /v1/fiches/:id/frais-hors-forfait, qui ajoute un frais hors forfait à une fiche en cours du visiteur connecté. Le corps est validé par le schemaFraisHorsForfait du cours Node.js : une date AAAA-MM-JJ (chaîne), un libellé et un montant. La réponse est 201, avec le frais créé (et son _id) et un en-tête Location ; 404 si la fiche n'est pas à lui, 409 si elle n'est plus en cours. En une seule opération d'écriture.

Voir la réponseMasquer la réponse de l'exercice 2
js
// routes
routeurFiches.post('/:id/frais-hors-forfait', exigerRole('visiteur'),
  valider({ params: schemaId, body: schemaFraisHorsForfait }), fiches.ajouterFraisHorsForfait);

// contrôleur
export async function ajouterFraisHorsForfait(req, res) {
  const { id } = req.valide.params;
  const { date, libelle, montant } = req.valide.body;
  const fiche = await Fiche.findOneAndUpdate(
    { _id: id, visiteur: req.utilisateur.id, etat: 'CR' },
    { $push: { fraisHorsForfait: { date: new Date(date), libelle, montant } } },
    { returnDocument: 'after', runValidators: true },
  );
  if (!fiche) {
    const existe = await Fiche.exists({ _id: id, visiteur: req.utilisateur.id });
    if (!existe) throw introuvable(id);
    throw new ErreurApi(409, 'fiche-cloturee', 'Fiche non modifiable', 'Seule une fiche en cours (CR) se modifie.');
  }
  const frais = fiche.fraisHorsForfait.at(-1);   // le dernier ajouté
  res.status(201).location(`/v1/fiches/${id}/frais-hors-forfait/${frais._id}`).json(frais);
}

$push ajoute l'élément au tableau imbriqué, dans le même document : pas de table de lignes ni de jointure, et l'écriture est atomique. Le sous-schéma fraisHorsForfait du modèle n'a pas _id: false : Mongoose donne donc un ObjectId à chaque frais, ce qui permet de le désigner plus tard (DELETE /v1/fiches/:id/frais-hors-forfait/:idFrais avec $pull). new Date(date) convertit la chaîne validée par Zod en date BSON (chapitre 2). Sur la fiche d'octobre de Lucas, {"date":"2026-10-12","libelle":" Parking gare Part-Dieu ","montant":14.5} répond 201 avec {"date":"2026-10-12T00:00:00.000Z","libelle":"Parking gare Part-Dieu","montant":14.5,"_id":"…"} : le libellé a été nettoyé par le trim() du schéma.

Tu avais trouvé ?

Exercice 3 sur 3 Associer erreurs et réponses Réussi À revoir

Pour chaque situation, donne le code HTTP renvoyé par l'API du chapitre et l'élément du code qui le produit.

  1. Un visiteur envoie PUT /v1/fiches/<id>/lignes-forfait avec {"ETP":0,"KM":"230","NUI":0,"REP":2} (quantité en chaîne).
  2. Un visiteur veut modifier les lignes de sa fiche de septembre, déjà validée.
  3. MongoDB est arrêté pendant qu'un visiteur consulte ses fiches.
  4. Deux requêtes POST /v1/fiches avec {"mois":"2026-12"} arrivent au même instant pour le même visiteur.
  5. Le cours Node.js est suivi à la lettre, avec id: Number(charge.sub) dans authentifier, et Lucas crée sa fiche de décembre.
Voir la réponseMasquer la réponse de l'exercice 3
  1. 422 : schemaLignesForfait attend z.number() et reçoit une chaîne (« Entrée invalide : nombre attendu, chaîne de caractères reçu », champ KM) ; le middleware valider lève l'ErreurApi avant le contrôleur. (Pas de z.coerce dans un corps JSON : le client doit envoyer le bon type.)
  2. 409 : le findOneAndUpdate de remplacerLignes ne trouve pas de fiche à l'état CR ; Fiche.exists montre qu'elle existe ; le contrôleur lève ErreurApi(409, 'fiche-cloturee', …).
  3. 500, au bout de cinq secondes : Mongoose ne trouve pas de serveur et lève une MongoServerSelectionError après le délai serverSelectionTimeoutMS. erreurMongo ne la reconnaît pas : le gestionnaire la journalise et renvoie le message générique « Une erreur inattendue est survenue. »
  4. 201 pour l'une, 409 pour l'autre : l'index unique visiteur_1_mois_1 refuse la seconde insertion (E11000), que creer traduit en 409 fiche-existante. Une vérification « la fiche existe-t-elle ? » dans le code, avant l'insertion, laisserait passer les deux.
  5. 422 : Number('6a0000000000000000000001') vaut NaN, donc req.utilisateur.id vaut NaN ; Fiche.create({ visiteur: NaN, … }) échoue à la validation de Mongoose (visiteur ne peut pas devenir un ObjectId), et erreurMongo traduit la ValidationError en 422. Sur une lecture, le filtre { visiteur: NaN } lève une CastError : 400. D'où la ligne à changer dans authentifier.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Sécuriser MongoDB : authentification, rôles et injection NoSQLRevoir le sommaire du cours