📐 Construire une app en JS
16 Premiere Version

Première version de notre application

La première version doit faire passer ce test.

it("A tenant can book an accomodation", async () => {
  // Injection des dépendances
  const app = new App(testDependencies());
 
  // Réalisation de la location
  await app.run([
    login({ email: "faketenant@mail.com", password: "secret" }),
    book({
      accomodationId: "accomodation-1",
      adults: 2,
      children: 3,
      from: new Date("2024-06-02"),
      to: new Date("2024-06-02"),
    }),
  ]);
 
  // Le calendrier comporte la réservation
  const bookings =
    await app.dependencies.bookings.listBookingsForAccomodationId(
      "accomodation-1"
    );
  expect(bookings).toHaveLength(1);
});

Installons un outil de test pour réaliser cela. J'ai une préférence pour vitest, mais n'importe quel outil peut faire l'affaire.

Créons notre projet et installons vitest

mkdir projet-booking
cd projet-booking
yarn init
yarn add -D vitest

Ajoutons un script dans le fichier package.json pour lancer l'exécution des tests :

{
  "name": "mon-projet-booking",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "test": "vitest"
  }
}

Reprenons ce que nous avons écrit jusqu'à présent pour y parvenir.

La commande de login

function login(payload) {
    const {email, password} = payload;
    return async function (dependencies, context) {
        const user = await dependencies.users.findByEmail(email);
        if (!user) {
            // Utilisateur inconnu
            return context.withError(UnknownUserEmail(email))
        }
 
        if (user.encryptedPassword !== encrypt(password)) {
            // Erreur de mot de passe
            return context.withError(WrongPassword(user))
        }
 
        // Tout est OK, ajoutons l'utilisateur au contexte
        // pour les commandes suivantes qui en auront besoin
        return context.withUser(user)
    }
}
 
 
function UnknownUserEmail(email) {
    return new Error(`Unknown user ${email}`)
}
 
function WrongPassword(user) {
    return new Error(`Wrong password ${user.email}`)
}
 
export function encrypt(text) {
  return text; // TODO : utiliser une fonction pour chiffrer le texte
}
 

La commande de réservation

function book(payload) {
    const {accomodationId,adults,children,from,to} = payload;
    return async function (dependencies, context) {
        const user = context.loggedUser; // Peut-être null !
        if (!user) {
            return context.withError(shouldBeLogged())
        }
        const booking = {
            tenantId:user.id, 
            accomodationId,
            hosts : {adults,children},
            interval : {from,to}
            };
        await dependencies.bookings.save(booking);
        return context
    }
}
 
function shouldBeLogged() {
    return new Error("User should be logged in")
}

Les dépendances de test

class MemoryUserRepository {
    // On indique par défaut un utilisateur déjà enregistré
    _users = [
        {id:"tenant-1", email:"faketenant@mail.com", encryptedPassword:encrypt("secret")}
    ]    
    async findByEmail(email) { return this._users.find(user => user.email === email)}
}
 
class MemoryBookingRepository {
    _bookings = [];
    async save(booking) { this._bookings.push(booking) };
    async listBookingsForAccomodationId(accomodationId) { 
        return this._bookings.filter(booking => booking.accomodationId === accomodationId)
    }
}
 
export const testDependencies = () => ({
    users : new MemoryUserRepository(),
    bookings : new MemoryBookingRepository()
})

La classe de contexte

class Context {
    withError (error) { 
        this.error = error;
        return this;
    }
    withUser (user) {
        this.loggedUser = user;
        return this;
    }
    isOk() {
        return !this.error
    }
}

La classe App

class App {
    constructor (dependencies) {
        this.dependencies = dependencies;
    }
    async run(usecases) {
        var context = new Context()
        for (const usecase of usecases) {
            // Inutile de continuer en cas d'erreur
            if (context.isOk()) {
                context = await usecase(this.dependencies, context)
            } 
        }
        return context
    }
}

Organisation des répertoires

Organisons les différents fichiers dans notre projet. On peut séparer les dépendances dans un répertoire infra afin de ne pas les mélanger avec le coeur de notre application que l'on regroupe dans un répertoire domain.

Comme nous n'avons que 2 commandes pour l'instant, adoptons une organisation simple avec 3 sous répertoires de domain :

  • app : contient les classes utilitaires App et Context
  • tests : pour y mettre notre fichier de test (mais d'autres suivront bientôt)
  • usecases : pour y mettre les commandes
projet-booking
 |- domain
    |- app
       |- App.js
       |- Context.js
    |- tests
       |- book.test.js
    |- usecases
       |- book.js
       |- login.js
 |- infra
    |- MemoryBookingRepository.js
    |- MemoryUserRepository.js
    |- testDependencies.js

Le code complet de cette première version est disponible ici (opens in a new tab).

Résultat du test

Et là, sous nos yeux, le test passe au vert.

✓ src/booking/domain/tests/book.test.js                                                                       
A tenant can book an accomodation                                                                             
 
 Test Files  1 passed (1)
      Tests  1 passed (1)

Et ce n'est que le début !