Le premier parcours utilisateur
Nous avons eu chaud, mais le parcours utilisateur de réservation d'un utilisateur déjà inscrit sur la plateforme est bien celui-là :
- se connecter à la plateforme
- disposer d'un bandeau comportant une date de début / fin de réservation
- voir tous les logements dont la location est disponible à cette date
- choisir un logement et le réserver en 1 clic
- voir sa réservation dans une page "mes réservations"
Avant d'écrire tout le HTML/CSS nécessaire pour faire ça, arrêtons-nous une minute.
Pour d'abord écrire un test.
Test du parcours utilisateur
En effet, ce parcours ne correspond pas exactement à notre test :
const app = new App(testDependencies());
await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({
accommodationId: "accommodation-1",
adults: 2,
children: 3,
from: new Date("2024-06-02"),
to: new Date("2024-06-04"),
}),
]);
const bookings = await app.dependencies.bookings.listBookingsForAccommodationId("accommodation-1");
expect(bookings).toHaveLength(1);Il manque 2 étapes :
- voir tous les logements dont la location est disponible à cette date
- voir sa réservation dans une page "mes réservations"
Pourquoi ?
Parce que ce ne sont pas des commandes qui changent l'état du système.
Ce sont des requêtes qui permettent de lire l'état du système.
Dans notre test, nous avons eu besoin de lire l'état du système tout à la fin. Pour vérifier que la commande avait bien été ajoutée à la liste des réservations.
const bookings = await app.dependencies.bookings.listBookingsForAccommodationId("accommodation-1");
expect(bookings).toHaveLength(1);Alors ajoutons les 2 requêtes manquantes.
Liste des réservations d'un vacancier
La plus simple est celle consistant à obtenir la liste des réservations d'un vacancier dans sa page "mes réservations".
Elle ressemble beaucoup à la requête déjà utilisée :
// Liste des réservations sur un logement
const bookings = await app.dependencies.bookings.listBookingsForAccommodationId("accommodation-1");
expect(bookings).toHaveLength(1);
// Liste des réservations détenues par un vacancier
const bookingsOfTenant = await app.dependencies.bookings.listBookingsForTenantId("tenant-1");
expect(bookingsOfTenant).toHaveLength(1);
// Encore mieux : les 2 réservations doivent être identiques
expect(bookingsOfTenant[0]).toEqual(bookings[0]);Lançons notre test, qui indique comme attendu que la fonction listBookingsForTenantId n'existe pas.
Alors ajoutons-la simplement à notre dépendance :
export class MemoryBookingRepository {
_bookings = [];
async save(booking) {
this._bookings.push(booking);
}
async listBookingsForAccommodationId(accommodationId) {
return this._bookings.filter(
(booking) => booking.accommodationId === accommodationId
);
}
async listBookingsForTenantId(tenantId) {
return this._bookings.filter(
(booking) => booking.tenantId === tenantId
);
}
}Et cette fois, le test passe du premier coup.
Arrêtons-nous une seconde sur la dernière assertion, parce qu'elle nous apprend quelque chose :
expect(bookingsOfTenant[0]).toEqual(bookings[0]);Elle ne pouvait pas échouer. Nos deux requêtes filtrent le même tableau _bookings, donc elles retournent le même objet — pas deux objets égaux, le même. Remplacez toEqual par toBe et le test passe toujours.
Ce n'est pas un détail. Cela veut dire que notre repository distribue ses objets internes à qui les demande, sans copie. N'importe quel appelant peut écrire bookings[0].tenantId = "quelqu'un d'autre" et modifier l'état du système en douce, sans passer par une commande.
Une vraie base de données ne ferait jamais cela : elle retournerait des lignes reconstruites à chaque requête. Notre implémentation en mémoire est donc plus permissive que la vraie — ce qui est exactement le genre d'écart qui produit des surprises le jour du branchement.
Notons-le, et poursuivons. Nous y reviendrons au chapitre 21, quand nos réservations seront faites de valeurs immutables : le problème disparaîtra tout seul.
Poursuivons le travail pour l'autre requête, qui est plus complexe.
Liste des logements disponibles
L'objectif est d'obtenir la liste de tous les logements dont la location est possible sur une période.
Le piège serait de basculer directement dans l'écriture d'une requête SQL complexe. Et d'ailleurs, à qui enverrait-on cette requête ? Nous n'avons pas encore évoqué la base de données !
Retournons la question. En l'absence de base de données, comment devrait-on faire ?
Nous avons besoin de croiser 2 sources de données différentes :
- la liste des réservations validées
- la liste des logements.
Ce qui pose déjà une question de rangement : cette requête retourne des logements, mais elle a besoin des réservations. Dans quel compartiment la mettre ?
Nous la plaçons dans MemoryBookingRepository, faute de mieux, en gardant à l'esprit que ce n'est pas sa place définitive — une requête qui croise deux sources appartient plutôt à un service de lecture dédié.
C'est aussi cela, la distinction commandes / requêtes du chapitre 4 : les requêtes ne se rangent pas comme les commandes.
(Le toDate utilisé ci-dessous est celui de notre dateProvider du chapitre 17.)
Voici une façon de faire :
- recenser toutes les réservations concernées par une période [début, fin]
- lister tous les logements qui ne sont pas concernés par ces réservations
Avant de coder cette nouvelle fonction dans notre MemoryBookingRepository, écrivons un test.
const app = new App(testDependencies());
await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({
accommodationId: "accommodation-1",
adults: 2,
children: 3,
from: new Date("2024-06-02"),
to: new Date("2024-06-04"),
}),
]);
const bookings = await app.dependencies.bookings.listBookingsForAccommodationId("accommodation-1");
expect(bookings).toHaveLength(1);
const bookingsOfTenant = await app.dependencies.bookings.listBookingsForTenantId("tenant-1");
expect(bookingsOfTenant[0]).toBe(bookings[0]); // on parle de la même réservation que précédemment
// Requête sur une période qui recouvre la réservation : le logement ne doit plus être proposé
const during = await app.dependencies.bookings.getAvailableAccommodations({
from: toDate("2024-06-01"),
to: toDate("2024-06-03")
});
expect(during.some(accommodation => accommodation.id === "accommodation-1")).toBe(false);
// Requête sur une période libre : le logement doit être proposé
const free = await app.dependencies.bookings.getAvailableAccommodations({
from: toDate("2024-05-12"),
to: toDate("2024-05-15")
});
expect(free.some(accommodation => accommodation.id === "accommodation-1")).toBe(true);Comme attendu, le test échoue sur l'absence de la fonction getAvailableAccommodations.
Ajoutons-la à notre dépendance.
Astuce : commencez par écrire le gabarit de la fonction, en retournant une liste vide :
async getAvailableAccommodations({ from, to }) {
return [];
}Relancez votre test, et observez l'erreur obtenue. Il ne s'agit plus désormais de l'absence de la fonction, mais de l'absence de logement.
AssertionError: expected false to be true // Object.is equality
- Expected
+ Received
- true
+ false
src/booking/domain/tests/book.test.js:116:5
114| (accommodation) => accommodation.id === "accommodation-1"
115| )
116| ).toBe(true);
| ^
117| });
118|Complétons cela pour faire passer le test au vert.
async getAvailableAccommodations({ from, to }) {
const bookedAccommodationsIds = this._bookings
.filter((booking) => isOverlapped(booking.interval, { from: toDate(from), to: toDate(to) }))
.map((booking) => booking.accommodationId);
return this._accommodations.filter(
(accommodation) =>
!bookedAccommodationsIds.some((id) => id === accommodation.id)
);
}Au passage, nous avons créé une fonction isOverlapped nécessaire pour déterminer les réservations qui recouvrent l'intervalle indiqué.
Et tous les tests sont au vert.
De l'intérêt de réécrire une base de données
Quel intérêt de passer du temps sur ce MemoryBookingRepository ?
Il ne sert qu'aux tests.
Dans la vraie version en production, il sera remplacé par un SQLBookingRepository.
Alors pourquoi s'évertuer à le coder juste pour le plaisir de faire passer un test au vert ?
C'est en effet discutable. Mais cette approche possède trois bénéfices intéressants :
-
Elle permet d'avoir une app fonctionnelle, même sans base de données installée. Cela accélère la possibilité de faire une démo rapidement. Et même d'onboarder un développeur ou un designer sans avoir besoin d'installer grand-chose sur son poste de dev.
-
Elle contraint le service d'accès aux données Elle oblige à définir des API simples, en nombre réduit. On bénéficiera d'un effet de sobriété vertueux sur le long terme.
-
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 de notre future base de données. Les choix techniques peuvent donc être repoussés, afin qu'ils soient guidés par les fonctionnalités réellement attendues pour notre projet. On évite le piège d'une décision trop anticipée. « Ah mince, on n'aurait jamais dû choisir MongoDB ».
Avant d'aller plus loin
Nos tests sont verts, et nous avons de quoi construire un premier écran.
Mais regardez le chemin parcouru depuis le chapitre 16 : la commande book() accumule les if, les dates traînent sous trois formes différentes, une fonction isOverlapped vient d'apparaître sans que personne ne se soit demandé ce qu'elle doit répondre aux cas limites, et notre repository distribue ses objets internes.
Rien de tout cela n'empêche le code de marcher. C'est précisément ce qui rend le moment dangereux.
Avant d'empiler une interface graphique par-dessus, prenons un chapitre pour ranger.