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
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.js | Avec 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.js | Une ligne change : l'identifiant n'est plus un nombre |
src/config.js | MONGODB_URI remplace les variables DB_HOST, DB_USER, DB_PASSWORD, DB_NAME |
src/db.js | La 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.js | L'identifiant devient un ObjectId ; les autres schémas ne changent pas |
controllers/fiches.controller.js | Les 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 :
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 :
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 :
// 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
};
// 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.
// 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 :
// 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
// 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
// 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 :
- Le propriétaire est dans le filtre.
lirecherche{ _id, visiteur: <l'utilisateur> }: la base ne renvoie que ce que le demandeur a le droit de voir. Une fiche qui appartient à un autre donne404, comme une fiche inexistante, sans révéler qu'elle existe (contrôle d'accès). - Le filtre du propriétaire passe en dernier. Dans
lister,Object.assignl'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. - Les règles d'état sont dans le filtre.
remplacerLignesetsupprimerexigentetat: '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,404ou409. - 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. - Le doublon est confié à l'index unique.
creerne vérifie pas d'abord si la fiche du mois existe : il tente l'insertion et traduit l'erreur E11000 en409. Seul l'index unique protège contre deux requêtes simultanées. - 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()etpopulatedonnent 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 :
// 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.
// 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.
| Erreur | D'où elle vient | Réponse |
|---|---|---|
ErreurApi | Un contrôleur, valider ou exigerRole : introuvable, données invalides, interdit, état incompatible | Son propre code : 403, 404, 409, 422 |
ValidationError | Mongoose, si une donnée invalide a passé Zod | 422, champ par champ |
CastError | Mongoose ne sait pas convertir une valeur (un identifiant non validé, par exemple) | 400 |
code === 11000 | Un index unique, si le contrôleur ne l'a pas déjà traduit | 409 Conflict |
MongoServerSelectionError | La 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.
curl -s "http://localhost:3000/v1/fiches?limite=2" -H "Authorization: Bearer $JETON_LUCAS"
{"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 :
{"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ête | Réponse |
|---|---|
GET /v1/fiches sans jeton | 401, non-authentifie, « Jeton absent. » |
GET /v1/fiches?visiteur=<identifiant d'Inès> avec le jeton de Lucas | 200, les 3 fiches de Lucas : le filtre du propriétaire l'emporte |
GET /v1/fiches?etat=XX&page=0 | 422, 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 Lucas | 404, fiche-introuvable |
GET /v1/fiches/123 | 422, {"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-forfait | 409, fiche-cloturee, « Seule une fiche en cours (CR) se modifie. » |
POST /v1/fiches avec le jeton de Sophie | 403, acces-refuse |
GET /v1/fiches/statistiques?mois=2026-09 avec le jeton de Sophie | 200 [{"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 fois | 204, 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).
// 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
// 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
// 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.
- Un visiteur envoie
PUT /v1/fiches/<id>/lignes-forfaitavec{"ETP":0,"KM":"230","NUI":0,"REP":2}(quantité en chaîne). - Un visiteur veut modifier les lignes de sa fiche de septembre, déjà validée.
- MongoDB est arrêté pendant qu'un visiteur consulte ses fiches.
- Deux requêtes
POST /v1/fichesavec{"mois":"2026-12"}arrivent au même instant pour le même visiteur. - Le cours Node.js est suivi à la lettre, avec
id: Number(charge.sub)dansauthentifier, et Lucas crée sa fiche de décembre.
Voir la réponseMasquer la réponse de l'exercice 3
- 422 :
schemaLignesForfaitattendz.number()et reçoit une chaîne (« Entrée invalide : nombre attendu, chaîne de caractères reçu », champKM) ; le middlewarevaliderlève l'ErreurApiavant le contrôleur. (Pas dez.coercedans un corps JSON : le client doit envoyer le bon type.) - 409 : le
findOneAndUpdatederemplacerLignesne trouve pas de fiche à l'étatCR;Fiche.existsmontre qu'elle existe ; le contrôleur lèveErreurApi(409, 'fiche-cloturee', …). - 500, au bout de cinq secondes : Mongoose ne trouve pas de serveur et lève une
MongoServerSelectionErroraprès le délaiserverSelectionTimeoutMS.erreurMongone la reconnaît pas : le gestionnaire la journalise et renvoie le message générique « Une erreur inattendue est survenue. » - 201 pour l'une, 409 pour l'autre : l'index unique
visiteur_1_mois_1refuse la seconde insertion (E11000), quecreertraduit en409fiche-existante. Une vérification « la fiche existe-t-elle ? » dans le code, avant l'insertion, laisserait passer les deux. - 422 :
Number('6a0000000000000000000001')vautNaN, doncreq.utilisateur.idvautNaN;Fiche.create({ visiteur: NaN, … })échoue à la validation de Mongoose (visiteurne peut pas devenir un ObjectId), eterreurMongotraduit laValidationErroren422. Sur une lecture, le filtre{ visiteur: NaN }lève uneCastError:400. D'où la ligne à changer dansauthentifier.
Tu avais trouvé ?