Le logement est-il libre ?
Il nous reste deux règles sur la liste du chapitre 14 :
- on ne peut pas réserver un logement qui n'est pas disponible
- un même vacancier ne peut avoir 2 locations sur la même période
Commençons par la première. C'est la règle du métier. Celle sans laquelle la plateforme n'a aucune valeur.
La démo qui ment
Ouvrez l'application. Réservez la villa du 2 au 4 juin. Elle disparaît de la liste.
Tout va bien.
Sauf que ce n'est pas notre commande qui la protège. C'est le filtre de la requête getAvailableAccommodations, écrit au chapitre 20. Notre commande book, elle, n'a jamais entendu parler de disponibilité.
En clair : le bouton "Réserver" n'est pas affiché, donc on ne peut pas cliquer dessus.
C'est la définition d'une sécurité par l'affichage. Ça tient exactement jusqu'au premier utilisateur qui garde un onglet ouvert pendant que quelqu'un d'autre réserve. Ou jusqu'à la première requête envoyée à la main. Ou jusqu'à la deuxième page de l'application, écrite dans six mois par quelqu'un qui aura oublié le filtre.
Le chapitre 28 nous a donné la formule : la requête filtre, la commande refuse. Notre commande ne refuse rien.
Le test
it("A tenant cannot book an accommodation already booked", async () => {
const app = new App(testDependencies());
// Une première réservation, du 2 au 4 juin
await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" }),
]);
// Une seconde, du 3 au 6 juin : elle recouvre la première
const session = await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-03", to: "2024-06-06" }),
]);
expect(session.error).toEqual(AccommodationNotAvailable("accommodation-1"));
const bookings = await app.dependencies.bookings
.listBookingsForAccommodationId("accommodation-1");
expect(bookings).toHaveLength(1); // et surtout : pas 2
});Notez la dernière assertion. Vérifier l'erreur ne suffit pas : ce qui compte, c'est que l'état du système n'ait pas bougé. Un test de commande contrôle toujours l'état, jamais seulement le message.
Et écrivons tout de suite le test de la rotation, celui qui nous a coûté si cher au chapitre 21 :
it("A tenant can book the very day the previous one leaves", async () => {
const app = new App(testDependencies());
await app.run([login({ ... }), book({ ..., from: "2024-06-02", to: "2024-06-04" })]);
const session = await app.run([
login({ ... }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-04", to: "2024-06-06" }), // arrivée le jour du départ
]);
expect(session.error).toBeUndefined();
});Écrivez toujours les deux. Un test qui vérifie qu'on refuse, et un test qui vérifie qu'on n'en refuse pas trop. C'est la deuxième moitié qui attrape les règles trop zélées — et une règle trop zélée coûte de l'argent en silence, personne ne remonte les réservations qu'il n'a pas pu faire.
La version naïve
Nous avons déjà tout ce qu'il faut :
const existing = await dependencies.bookings
.listBookingsForAccommodationId(accommodationId);
if (existing.some((booking) => booking.stay.overlaps(stay.value))) {
return context.withError(AccommodationNotAvailable(accommodationId));
}Les tests passent. stay.overlaps était écrit depuis le chapitre 21, avec ses six tests. Nous ne faisons que l'appeler.
Prenons un instant pour apprécier : l'invariant le plus important de la plateforme tient en trois lignes, parce que la question difficile — est-ce que deux séjours se chevauchent ? — avait été posée, débattue avec l'expert métier et testée ailleurs, deux mois plus tôt.
C'est le rendement du travail fait au chapitre 21.
La version naïve ne passera pas l'hiver
Regardons cette ligne avec les yeux d'un système en production :
const existing = await dependencies.bookings.listBookingsForAccommodationId(accommodationId);Elle charge toutes les réservations du logement. Toutes celles de 2024, de 2023, de 2022. Pour n'en garder aucune, la plupart du temps.
Sur un tableau en mémoire de six éléments, c'est gratuit. Sur une base de données au bout de trois ans, c'est une requête qui grossit indéfiniment pour répondre à une question dont la réponse est un booléen.
Le chapitre 20 nous avait prévenus de ce bénéfice-là :
Elle va guider le choix futur de la base de données. En concevant une dépendance de test, on entre plus précisément dans les fonctionnalités attendues.
Alors entrons-y. Nommons la question réellement posée :
export class MemoryBookingRepository {
// ...
/** Les réservations actives de ce logement qui recouvrent la période. */
async findOverlapping(accommodationId, stay) {
return this._bookings.filter(
(booking) =>
booking.accommodationId === accommodationId && booking.stay.overlaps(stay)
);
}
}Et la commande devient :
const conflicts = await dependencies.bookings.findOverlapping(accommodationId, stay.value);
if (conflicts.length > 0) {
return context.withError(AccommodationNotAvailable(accommodationId));
}En mémoire, c'est exactement le même travail. Mais la méthode dit maintenant ce qu'elle veut, et pas comment l'obtenir. Le jour où nous écrirons SQLBookingRepository, elle deviendra :
select * from bookings
where accommodation_id = $1
and starts_on < $2 -- to
and $3 < ends_on -- fromUne ligne, un index, un temps constant.
Le prix à payer : la règle existe en deux exemplaires
Regardez bien ce que nous venons de faire, parce que c'est le compromis le plus important de toute l'architecture hexagonale, et que personne ne le formule jamais clairement.
La règle "deux séjours se chevauchent quand from1 < to2 && from2 < to1" est maintenant écrite deux fois : une fois en JavaScript dans Stay.overlaps, une fois en SQL dans le where.
C'est une duplication. Elle est inévitable : on ne peut pas envoyer une méthode JavaScript à PostgreSQL, et on ne peut pas ramener quatre ans de lignes dans Node pour les filtrer.
Ce que l'on peut faire, en revanche, c'est empêcher les deux versions de diverger. C'est le rôle des tests de contrat : une seule suite de tests, exécutée successivement contre MemoryBookingRepository et contre SQLBookingRepository. Le jour où le < devient un <= dans le SQL, la suite le dit.
Nous les écrirons au chapitre 38. Notez simplement, dès maintenant, ce principe :
Chaque fois qu'une règle du domaine traverse la frontière et se réécrit dans une autre technologie, elle doit être tenue par un test de contrat.
Sans cela, la rotation du samedi remarchera en mémoire et cassera en production. Et vous chercherez longtemps.
L'invariant qui n'existait pas
Passons à la dernière règle de la liste : un même vacancier ne peut avoir 2 locations sur la même période.
Avant de coder, appelons l'expert métier. Comme le chapitre 17 nous y invitait.
"Ah non, surtout pas ! J'ai des clients qui louent trois gîtes pour la même semaine, pour un mariage ou un anniversaire. C'est même mon meilleur panier moyen. Vous ne pouvez pas m'interdire ça."
Voilà.
Nous avions écrit cette règle au chapitre 14, dans une liste, sans jamais la vérifier. Elle nous semblait raisonnable. Elle aurait supprimé les plus grosses réservations de la plateforme.
Rayons-la.
Et remarquons trois choses, parce qu'elles valent bien plus que le code que nous n'avons pas écrit.
Une règle inventée par le développeur est une régression que personne ne signalera. Un utilisateur qui n'arrive pas à faire ce qu'il veut ne vous écrit pas : il s'en va. Les invariants faux ne remontent jamais par le support.
Le coût de la question était de trente secondes. Le coût de la règle aurait été de plusieurs milliers d'euros par an. C'est le meilleur rendement horaire de votre carrière.
Le seul cas problématique était déjà couvert. Réserver deux fois le même logement sur la même période est impossible depuis dix lignes plus haut : la disponibilité s'en charge. La règle qui restait ne protégeait rien.
Notre liste du chapitre 14 est terminée :
la date de fin doit être postérieure à la date d'arrivée✅pas de réservation dans le passé✅le nombre d'occupants ne dépasse pas la capacité✅on ne peut pas réserver un logement indisponible✅un vacancier ne peut avoir 2 locations sur la même période❌ l'expert métier dit non
Un test que nous n'allons pas faire passer
Reste une question, et vous l'avez peut-être déjà en tête depuis le début du chapitre.
Que se passe-t-il si deux vacanciers cliquent en même temps ?
Le chapitre 6 nous avait fait écrire ce test, il y a bien longtemps :
it.fails("Two simultaneous bookings : only one is accepted", async () => {
const app = new App(testDependencies());
const [first, second] = await Promise.all([
app.run([login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" })]),
app.run([login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" })]),
]);
const bookings = await app.dependencies.bookings
.listBookingsForAccommodationId("accommodation-1");
expect(bookings).toHaveLength(1);
});Il échoue. Deux réservations sont enregistrées.
Pourquoi ? Notre commande fait un await pour lire les réservations existantes, puis un await pour enregistrer la sienne. Entre les deux, elle rend la main à la boucle d'événements. La seconde exécution passe pendant ce trou, lit un calendrier encore vide, et conclut que le logement est libre.
C'est très exactement le check-then-act du chapitre 6, et aucune ligne de code dans la commande ne le corrigera : ajouter un troisième contrôle ne fait que déplacer le trou.
Le remède n'est pas dans le domaine. Il est dans l'infrastructure — une transaction, un verrou, une contrainte d'unicité en base — et c'est le sujet du chapitre 39.
En attendant, notez le it.fails de Vitest : il déclare qu'un test est attendu en échec. La suite reste verte, mais le jour où quelqu'un corrige le problème, it.fails passe au rouge et réclame qu'on le transforme en it.
C'est une façon honnête de documenter un défaut connu. Bien meilleure qu'un it.skip, qu'on oublie, ou qu'un commentaire // TODO, que personne n'exécute.
Notre domaine tient debout. Retournons dans l'interface, où un mot de passe traîne en clair au milieu du code.
Le code de cette étape est disponible ici (opens in a new tab).