📐 Construire une app en JS
18. Améliorer la commande

Améliorer notre commande

Que faut-il au minimum pour satisfaire le test d'une réservation dans le passé ?

D'abord exécutons-le pour nous assurer qu'il échoue. Vous serez surpris du nombre de fois où un test est vert sans que l'on ait rien fait (souvent, c'est mauvais signe !).

const app = new App(testDependencies())
 
// Tentative de réservation dans le passé
const session = await app.run([
    login({email:"faketenant@mail.com", password:"secret"}),
    book({
        accommodationId:"accommodation-1",
        adults:2, children:3,
        from : new Date("2023-06-02"), // Sûr d'être dans le passé
        to :   new Date("2023-06-02"),
    })
]);
 
expect(session.error).toEqual(InvalidInterval())

Ouf, nous avons bien un échec :

 FAIL  domain/tests/book.test.js > A tenant cannot book an accommodation in the past
AssertionError: expected undefined to be Error: This date interval is not correct // Object.is equality

- Expected:
[Error: This date interval is not correct]

+ Received:
undefined

Corrigeons la commande pour régler ça.

Ajout de l'invariant dans notre commande

Rien de compliqué. Il suffit de vérifier que la date de début est dans le futur. Peu importe la date de fin.

function book(payload) {
    const {accommodationId,adults,children,from,to} = payload;
    return async function (dependencies, context) {
        const user = context.loggedUser;
        if (!user) {
            return context.withError(shouldBeLogged())
        }
        const {toDate, isBefore, now} = dependencies.dateProvider
        if (isBefore(toDate(from), now())) {
            return context.withError(InvalidInterval())
        }
        const booking = {
            tenantId:user.id,
            accommodationId,
            hosts : {adults,children},
            interval : {from,to}
            };
        await dependencies.bookings.save(booking);
        return context
    }
}
 
function shouldBeLogged() {
    return new Error("User should be logged in")
}
 
function InvalidInterval() {
    return new Error("This date interval is not correct")
}

Bingo, ces quelques lignes suffisent à passer le test.

Aller à un rythme tranquille

Lorsqu'on modifie notre commande book, un point saute aux yeux : il nous faut valider que la date de fin de réservation est après la date de début. C'est un cas d'erreur évident.

La tentation est grande d'aller vite en ajoutant le bout de code qui teste aussi cela.

Mais adoptons une attitude différente : s'interdire de toucher au code de la commande tant que tous les tests passent.

Donc gardons notre flow et ajoutons un test. Sur un malentendu, il pourrait déjà être au vert.

const app = new App(testDependencies())
 
// Tentative de réservation dans le passé
const session = await app.run([
    login({email:"faketenant@mail.com", password:"secret"}),
    book({
        accommodationId:"accommodation-1",
        adults:2, children:3,
        from : new Date("2023-06-22"), // Dans le futur (notre provider de test est figé au 12 juin 2023)
        to :   new Date("2023-06-12"), // Oups..
    })
]);
 
expect(session.error).toEqual(InvalidInterval())

Comme attendu le test échoue, nous donnant le droit de modifier le code.

Attardons-nous sur la condition à vérifier : faut-il que le jour to soit strictement supérieur à from ? Ou les dates peuvent-elles être égales ?

Posons la question à l'expert métier. Il vous répond que l'on réserve un nombre de nuitées. Donc que la date de départ doit être a minima le lendemain de la date d'arrivée. Une égalité est un cas d'erreur.

Cela interroge la fonction isBefore de notre provider de date. Nous voulons qu'elle retourne vrai lorsque les dates sont égales.

Cela appelle 2 questions :

  1. Ne devrait-on pas renommer la fonction isBefore par isBeforeOrEqual pour ne plus se poser la question plus tard ?

Oui probablement.

  1. Est-ce que les endroits où cette fonction est déjà utilisée s'attendent bien à ce comportement ?

Bonne question. Nous avons déjà utilisé cette fonction pour vérifier que la réservation commence dans le futur. Mais peut-on faire une réservation qui commence le jour même ?

Retournons voir l'expert métier, qui nous dit qu'il y a un préavis d'au moins 1 jour. Et voilà un nouvel invariant qui mérite lui aussi son test ! Nous le coderons au chapitre 21, quand nous aurons de quoi le loger proprement.

Test du cas d'égalité

Le test d'une réservation qui ne respecte pas le minimum d'une nuitée :

const app = new App(testDependencies())
 
// Tentative de réservation dans le passé
const session = await app.run([
    login({email:"faketenant@mail.com", password:"secret"}),
    book({
        accommodationId:"accommodation-1",
        adults:2, children:3,
        from : new Date("2023-06-22"),
        to :   new Date("2023-06-22"), // Non respect d'une nuitée mini
    })
]);
 
expect(session.error).toEqual(InvalidInterval())

Nous avons désormais deux tests qui échouent. Et qui nous guident sur les modifications à réaliser.

Modifications du provider de date et de la commande de réservation

D'abord, attachons-nous à clarifier le provider de date pour transformer la fonction isBefore en isBeforeOrEqual :

export const systemDateProvider = {
  now: () => Date.now(),
  toDate: (something) => new Date(something).valueOf(),
  sameDay: (date1, date2, timeZone = "Europe/Paris") =>
    new Date(date1).toLocaleDateString("fr-FR", { timeZone }) ===
    new Date(date2).toLocaleDateString("fr-FR", { timeZone }),
  isBeforeOrEqual: (date1, date2) => date1 <= date2,
};

Puis apportons les modifications à notre commande :

function book(payload) {
  const { accommodationId, adults, children, from, to } = payload;
  return async function (dependencies, context) {
    const user = context.loggedUser; // Peut-être null !
    if (!user) {
      return context.withError(shouldBeLogged());
    }
    const { toDate, isBeforeOrEqual, now } = dependencies.dateProvider;
    if (isBeforeOrEqual(toDate(from), now())) {
      return context.withError(InvalidInterval());
    }
    if (isBeforeOrEqual(toDate(to), toDate(from))) {
      return context.withError(InvalidInterval());
    }

Relançons les tests. Et surprise :

 ❯ domain/tests/book.test.js (3 tests | 1 failed)
   × A tenant can book an accommodation
   ✓ A tenant cannot book an accommodation in the past
   ✓ A tenant should provide a valid interval

Les 2 nouveaux tests passent. Mais le premier ne passe plus !

Pourquoi ?

Parce que notre premier test, écrit tout au début, invoquait cette commande :

book({
    accommodationId:"accommodation-1",
    adults:2, children:3,
    from : new Date("2024-06-02"), // C'est bien dans le futur, notre provider de test est figé en 2023 !
    to :   new Date("2024-06-02"),
})

Cela nous avait échappé ! En ajoutant l'invariant sur le nombre de nuitées minimum, ce test échoue désormais.

Corrigeons-le pour le mettre en cohérence avec le métier, en remplaçant la date de fin par "2024-06-04". Ainsi, tous nos tests passent.

Le flow de réalisation du code

Voilà notre backend sur les rails.

En conclusion, revenons sur notre démarche de développement qui s'articule en itérations comportant 3 étapes :

  1. Réfléchir à ce que le code doit faire, en impliquant l'expert métier
  2. Coder un test qui contrôle l'attendu, et s'assurer qu'il échoue
  3. Apporter les modifications minimales pour faire passer le test au vert.

Cette démarche apporte beaucoup de sérénité. Ce flow est incroyablement efficace. Vous serez surpris de la productivité obtenue. Elle s'explique par des itérations qui forcent à découper un problème complexe en problèmes plus petits, et par des tests qui garantissent qu'il n'y a pas de retour en arrière.

On gravit la pente pas-à-pas, sans fatigue, à rythme constant, et sans jamais chuter en arrière !

C'est à ce moment que votre client vous appelle.