Développement local avec la suite d'émulateurs Firebase

Restez organisé à l'aide des collections Enregistrez et classez les contenus selon vos préférences.

1. Avant de commencer

Les outils backend sans serveur comme Cloud Firestore et Cloud Functions sont très faciles à utiliser, mais peuvent être difficiles à tester. La suite d'émulateurs locaux Firebase vous permet d'exécuter des versions locales de ces services sur votre ordinateur de développement afin que vous puissiez développer votre application rapidement et en toute sécurité.

Conditions préalables

  • Un éditeur simple tel que Visual Studio Code, Atom ou Sublime Text
  • Node.js 10.0.0 ou supérieur (pour installer Node.js, utilisez nvm , pour vérifier votre version, exécutez node --version )
  • Java 7 ou supérieur (pour installer Java, utilisez ces instructions , pour vérifier votre version, exécutez java -version )

Ce que tu vas faire

Dans cet atelier de programmation, vous allez exécuter et déboguer une application d'achat en ligne simple alimentée par plusieurs services Firebase :

  • Cloud Firestore : une base de données NoSQL sans serveur, évolutive à l'échelle mondiale, avec des fonctionnalités en temps réel.
  • Cloud Functions : un code backend sans serveur qui s'exécute en réponse à des événements ou à des requêtes HTTP.
  • Firebase Authentication : un service d'authentification géré qui s'intègre à d'autres produits Firebase.
  • Firebase Hosting : hébergement rapide et sécurisé pour les applications Web.

Vous connecterez l'application à Emulator Suite pour permettre le développement local.

2589e2f95b74fa88.png

Vous apprendrez également à :

  • Comment connecter votre application à Emulator Suite et comment les différents émulateurs sont connectés.
  • Comment fonctionnent les règles de sécurité Firebase et comment tester les règles de sécurité Firestore par rapport à un émulateur local.
  • Comment écrire une fonction Firebase qui est déclenchée par des événements Firestore et comment écrire des tests d'intégration qui s'exécutent sur Emulator Suite.

2. Configurer

Obtenir le code source

Dans cet atelier de programmation, vous démarrez avec une version de l'exemple The Fire Store qui est presque terminée. La première chose à faire est donc de cloner le code source :

$ git clone https://github.com/firebase/emulators-codelab.git

Ensuite, déplacez-vous dans le répertoire codelab, où vous travaillerez pour le reste de ce codelab :

$ cd emulators-codelab/codelab-initial-state

Maintenant, installez les dépendances pour pouvoir exécuter le code. Si vous utilisez une connexion Internet plus lente, cela peut prendre une minute ou deux :

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

# Move back into the previous directory
$ cd ../

Obtenir la CLI Firebase

La suite d'émulateurs fait partie de la CLI Firebase (interface de ligne de commande) qui peut être installée sur votre machine avec la commande suivante :

$ npm install -g firebase-tools

Ensuite, confirmez que vous disposez de la dernière version de la CLI. Ce laboratoire de programmation devrait fonctionner avec la version 9.0.0 ou ultérieure, mais les versions ultérieures incluent davantage de corrections de bogues.

$ firebase --version
9.6.0

Connectez-vous à votre projet Firebase

Si vous n'avez pas de projet Firebase, dans la console Firebase , créez un nouveau projet Firebase. Notez l'ID de projet que vous choisissez, vous en aurez besoin plus tard.

Nous devons maintenant connecter ce code à votre projet Firebase. Exécutez d'abord la commande suivante pour vous connecter à la CLI Firebase :

$ firebase login

Exécutez ensuite la commande suivante pour créer un alias de projet. Remplacez $YOUR_PROJECT_ID par l'ID de votre projet Firebase.

$ firebase use $YOUR_PROJECT_ID

Vous êtes maintenant prêt à exécuter l'application !

3. Exécutez les émulateurs

Dans cette section, vous exécuterez l'application localement. Cela signifie qu'il est temps de démarrer Emulator Suite.

Démarrer les émulateurs

Depuis le répertoire source de l'atelier de programmation, exécutez la commande suivante pour démarrer les émulateurs :

$ firebase emulators:start --import=./seed

Vous devriez voir une sortie comme celle-ci :

$ firebase emulators:start --import=./seed
i  emulators: Starting emulators: auth, functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, pubsub
i  firestore: Importing data from /Users/samstern/Projects/emulators-codelab/codelab-initial-state/seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/samstern/Projects/emulators-codelab/codelab-initial-state/functions" for Cloud Functions...
✔  functions[calculateCart]: firestore function initialized.

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://localhost:4000                │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ localhost:5001 │ http://localhost:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ localhost:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at localhost:4400
  Other reserved ports: 4500

Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.

Une fois que vous voyez le message Tous les émulateurs ont démarré , l'application est prête à être utilisée.

Connectez l'application Web aux émulateurs

Sur la base du tableau des journaux, nous pouvons voir que l'émulateur Cloud Firestore écoute sur le port 8080 et que l'émulateur d'authentification écoute sur le port 9099 .

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ localhost:5001 │ http://localhost:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ localhost:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘

Connectons votre code frontal à l'émulateur, plutôt qu'à la production. Ouvrez le fichier public/js/homepage.js et recherchez la fonction onDocumentReady . Nous pouvons voir que le code accède aux instances standards Firestore et Auth :

public/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

Mettons à jour les objets db et auth pour qu'ils pointent vers les émulateurs locaux :

public/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

  // ADD THESE LINES
  if (location.hostname === "localhost") {
    console.log("localhost detected!");
    auth.useEmulator("http://localhost:9099");
    db.useEmulator("localhost", 8080);
  }

Désormais, lorsque l'application s'exécute sur localhost (servi par l'émulateur d'hébergement), le client Firestore pointe également vers l'émulateur local plutôt que vers une base de données de production.

Ouvrez l'interface utilisateur de l'émulateur

Dans votre navigateur Web, accédez à http://localhost:4000/ . Vous devriez voir l'interface utilisateur d'Emulator Suite.

Écran d'accueil de l'interface utilisateur des émulateurs

Cliquez pour voir l'interface utilisateur de l'émulateur Firestore. La collection items contient déjà des données en raison des données importées avec l'indicateur --import .

4ef88d0148405d36.png

4. Exécutez l'application

Ouvrez l'application

Dans votre navigateur Web, accédez à http://localhost:5000 et vous devriez voir le Fire Store s'exécuter localement sur votre machine !

939f87946bac2ee4.png

Utilisez l'application

Choisissez un article sur la page d'accueil et cliquez sur Ajouter au panier . Malheureusement, vous rencontrerez l'erreur suivante :

a11bd59933a8e885.png

Corrigeons ce bug ! Comme tout fonctionne dans les émulateurs, nous pouvons expérimenter sans nous soucier d'affecter les données réelles.

5. Déboguer l'application

Trouvez le bogue

Ok, regardons dans la console développeur de Chrome. Appuyez sur Control+Shift+J (Windows, Linux, Chrome OS) ou Command+Option+J (Mac) pour voir l'erreur sur la console :

74c45df55291dab1.png

Il semble qu'il y ait eu une erreur dans la méthode addToCart , examinons cela. Où essayons-nous d'accéder à quelque chose appelé uid dans cette méthode et pourquoi serait-il null ? À l'heure actuelle, la méthode ressemble à ceci dans public/js/homepage.js :

public/js/homepage.js

  addToCart(id, itemData) {
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

Ah ! Nous ne sommes pas connectés à l'application. Selon les documents Firebase Authentication , lorsque nous ne sommes pas connectés, auth.currentUser est null . Ajoutons une vérification pour cela :

public/js/homepage.js

  addToCart(id, itemData) {
    // ADD THESE LINES
    if (this.auth.currentUser === null) {
      this.showError("You must be signed in!");
      return;
    }

    // ...
  }

Testez l'application

Maintenant, actualisez la page, puis cliquez sur Ajouter au panier . Vous devriez obtenir une erreur plus agréable cette fois :

c65f6c05588133f7.png

Mais si vous cliquez sur Connexion dans la barre d'outils supérieure, puis cliquez à nouveau sur Ajouter au panier , vous verrez que le panier est mis à jour.

Cependant, il semble que les chiffres ne soient pas du tout corrects :

239f26f02f959eef.png

Ne vous inquiétez pas, nous allons bientôt corriger ce bug. Tout d'abord, examinons en profondeur ce qui s'est réellement passé lorsque vous avez ajouté un article à votre panier.

6. Déclencheurs de fonctions locales

Cliquer sur Ajouter au panier lance une chaîne d'événements impliquant plusieurs émulateurs. Dans les journaux de la CLI Firebase, vous devriez voir quelque chose comme les messages suivants après avoir ajouté un article à votre panier :

i  functions: Beginning execution of "calculateCart"
i  functions: Finished "calculateCart" in ~1s

Quatre événements clés se sont produits pour produire ces journaux et la mise à jour de l'interface utilisateur que vous avez observée :

68c9323f2ad10f7a.png

1) Écriture Firestore - Client

Un nouveau document est ajouté à la collection Firestore /carts/{cartId}/items/{itemId}/ . Vous pouvez voir ce code dans la fonction addToCart dans public/js/homepage.js :

public/js/homepage.js

  addToCart(id, itemData) {
    // ...
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

2) Fonction cloud déclenchée

La fonction Cloud calculateCart écoute tous les événements d'écriture (création, mise à jour ou suppression) qui se produisent sur les éléments du panier à l'aide du déclencheur onWrite , que vous pouvez voir dans functions/index.js :

fonctions/index.js

exports.calculateCart = functions.firestore
    .document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    }
);

3) Écriture Firestore - Administrateur

La fonction calculateCart lit tous les articles du panier et additionne la quantité et le prix total, puis elle met à jour le document "cart" avec les nouveaux totaux (voir cartRef.update(...) ci-dessus).

4) Firestore Lire - Client

L'interface Web est abonnée pour recevoir des mises à jour sur les modifications apportées au panier. Il obtient une mise à jour en temps réel après que la fonction Cloud a écrit les nouveaux totaux et mis à jour l'interface utilisateur, comme vous pouvez le voir dans public/js/homepage.js :

public/js/homepage.js

this.cartUnsub = cartRef.onSnapshot(cart => {
   // The cart document was changed, update the UI
   // ...
});

résumer

Bon travail! Vous venez de configurer une application entièrement locale qui utilise trois émulateurs Firebase différents pour des tests entièrement locaux.

db82eef1706c9058.gif

Mais attendez, il y a plus ! Dans la section suivante, vous apprendrez :

  • Comment écrire des tests unitaires qui utilisent les émulateurs Firebase.
  • Comment utiliser les émulateurs Firebase pour déboguer vos règles de sécurité.

7. Créez des règles de sécurité adaptées à votre application

Notre application Web lit et écrit des données, mais jusqu'à présent, nous ne nous sommes pas du tout inquiétés de la sécurité. Cloud Firestore utilise un système appelé "Règles de sécurité" pour déclarer qui a accès aux données en lecture et en écriture. La suite d'émulateurs est un excellent moyen de prototyper ces règles.

Dans l'éditeur, ouvrez le fichier emulators-codelab/codelab-initial-state/firestore.rules . Vous verrez que nous avons trois sections principales dans nos règles :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

À l'heure actuelle, n'importe qui peut lire et écrire des données dans notre base de données ! Nous voulons nous assurer que seules les opérations valides passent et que nous ne divulguons aucune information sensible.

Au cours de cet atelier de programmation, conformément au principe du moindre privilège, nous verrouillerons tous les documents et ajouterons progressivement l'accès jusqu'à ce que tous les utilisateurs aient tous les accès dont ils ont besoin, mais pas plus. Mettons à jour les deux premières règles pour refuser l'accès en définissant la condition sur false :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

8. Exécutez les émulateurs et les tests

Démarrer les émulateurs

Sur la ligne de commande, assurez-vous que vous êtes dans emulators-codelab/codelab-initial-state/ . Vous pouvez toujours avoir les émulateurs en cours d'exécution à partir des étapes précédentes. Sinon, redémarrez les émulateurs :

$ firebase emulators:start --import=./seed

Une fois que les émulateurs sont en cours d'exécution, vous pouvez exécuter des tests localement sur eux.

Exécutez les tests

Sur la ligne de commande dans un nouvel onglet de terminal depuis le répertoire emulators-codelab/codelab-initial-state/

Déplacez-vous d'abord dans le répertoire functions (nous y resterons pour le reste du codelab):

$ cd functions

Exécutez maintenant les tests moka dans le répertoire des fonctions et faites défiler vers le haut de la sortie :

# Run the tests
$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    1) can be created and updated by the cart owner
    2) can be read only by the cart owner

  shopping cart items
    3) can be read only by the cart owner
    4) can be added only by the cart owner

  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items


  0 passing (364ms)
  1 pending
  4 failing

En ce moment, nous avons quatre échecs. Au fur et à mesure que vous créez le fichier de règles, vous pouvez mesurer les progrès en observant la réussite de plusieurs tests.

9. Accès sécurisé au panier

Les deux premiers échecs sont les tests "panier" qui testent que :

  • Les utilisateurs peuvent uniquement créer et mettre à jour leurs propres paniers
  • Les utilisateurs ne peuvent lire que leurs propres paniers

fonctions/test.js

  it('can be created and updated by the cart owner', async () => {
    // Alice can create her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Bob can't create Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Alice can update her own cart with a new total
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").update({
      total: 1
    }));

    // Bob can't update Alice's cart with a new total
    await firebase.assertFails(bobDb.doc("carts/alicesCart").update({
      total: 1
    }));
  });

  it("can be read only by the cart owner", async () => {
    // Setup: Create Alice's cart as admin
    await admin.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    });

    // Alice can read her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").get());

    // Bob can't read Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").get());
  });

Faisons passer ces tests. Dans l'éditeur, ouvrez le fichier de règles de sécurité, firestore.rules , et mettez à jour les instructions dans match /carts/{cartID} :

firestore.rules

rules_version = '2';
service cloud.firestore {
    // UPDATE THESE LINES
    match /carts/{cartID} {
      allow create: if request.auth.uid == request.resource.data.ownerUID;
      allow read, update, delete: if request.auth.uid == resource.data.ownerUID;
    }

    // ...
  }
}

Ces règles autorisent désormais uniquement l'accès en lecture et en écriture au propriétaire du panier.

Pour vérifier les données entrantes et l'authentification de l'utilisateur, nous utilisons deux objets disponibles dans le contexte de chaque règle :

  • L'objet request contient des données et des métadonnées sur l'opération qui est tentée.
  • Si un projet Firebase utilise Firebase Authentication , l'objet request.auth décrit l'utilisateur qui fait la demande.

10. Accès au chariot de test

La suite d'émulateurs met automatiquement à jour les règles chaque fois que firestore.rules est enregistré. Vous pouvez confirmer que l'émulateur a mis à jour les règles en regardant dans l'onglet exécutant l'émulateur le message Rules updated :

5680da418b420226.png

Relancez les tests et vérifiez que les deux premiers tests réussissent maintenant :

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    1) can be read only by the cart owner
    2) can be added only by the cart owner

  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items

  2 passing (482ms)
  1 pending
  2 failing

Bon travail! Vous avez maintenant un accès sécurisé aux paniers. Passons au prochain test raté.

11. Vérifiez le flux "Ajouter au panier" dans l'interface utilisateur

À l'heure actuelle, bien que les propriétaires de panier lisent et écrivent dans leur panier, ils ne peuvent pas lire ou écrire des articles individuels dans leur panier. En effet, bien que les propriétaires aient accès au document du panier, ils n'ont pas accès à la sous-collection d'articles du panier.

Il s'agit d'un état cassé pour les utilisateurs.

Revenez à l'interface utilisateur Web, qui s'exécute sur http://localhost:5000, et essayez d'ajouter quelque chose à votre panier. Vous obtenez une erreur Permission Denied , visible depuis la console de débogage, car nous n'avons pas encore accordé aux utilisateurs l'accès aux documents créés dans la sous-collection items .

12. Autoriser l'accès aux articles du panier

Ces deux tests confirment que les utilisateurs ne peuvent ajouter ou lire des articles qu'à partir de leur propre panier :

  it("can be read only by the cart owner", async () => {
    // Alice can read items in her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/milk").get());

    // Bob can't read items in alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/milk").get())
  });

  it("can be added only by the cart owner",  async () => {
    // Alice can add an item to her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));

    // Bob can't add an item to alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));
  });

Nous pouvons donc écrire une règle qui autorise l'accès si l'utilisateur actuel a le même UID que le propriétaireUID sur le document du panier. Puisqu'il n'est pas nécessaire de spécifier des règles différentes pour create, update, delete , vous pouvez utiliser une règle write , qui s'applique à toutes les requêtes qui modifient les données.

Mettez à jour la règle pour les documents de la sous-collection d'éléments. Le get dans le conditionnel lit une valeur de Firestore - dans ce cas, le ownerUID sur le document du panier.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ...

    // UPDATE THESE LINES
    match /carts/{cartID}/items/{itemID} {
      allow read, write: if get(/databases/$(database)/documents/carts/$(cartID)).data.ownerUID == request.auth.uid;
    }

    // ...
  }
}

13. Tester l'accès aux articles du panier

Nous pouvons maintenant relancer le test. Faites défiler vers le haut de la sortie et vérifiez que d'autres tests réussissent :

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    ✓ can be read only by the cart owner (111ms)
    ✓ can be added only by the cart owner


  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items


  4 passing (401ms)
  1 pending

Bon! Maintenant, tous nos tests réussissent. Nous avons un test en attente, mais nous y reviendrons en quelques étapes.

14. Vérifiez à nouveau le flux "ajouter au panier"

Revenez à l'interface Web ( http://localhost:5000 ) et ajoutez un article au panier. Il s'agit d'une étape importante pour confirmer que nos tests et nos règles correspondent aux fonctionnalités requises par le client. (Rappelez-vous que la dernière fois que nous avons essayé, les utilisateurs de l'interface utilisateur n'ont pas pu ajouter d'articles à leur panier !)

69ad26cee520bf24.png

Le client recharge automatiquement les règles lorsque le firestore.rules est enregistré. Alors, essayez d'ajouter quelque chose au panier.

résumer

Bon travail! Vous venez d'améliorer la sécurité de votre application, étape essentielle pour la préparer à la production ! S'il s'agissait d'une application de production, nous pourrions ajouter ces tests à notre pipeline d'intégration continue. Cela nous donnerait confiance à l'avenir que nos données de panier auront ces contrôles d'accès, même si d'autres modifient les règles.

ba5440b193e75967.gif

Mais attendez, il y a plus !

si vous continuez, vous apprendrez :

  • Comment écrire une fonction déclenchée par un événement Firestore
  • Comment créer des tests qui fonctionnent sur plusieurs émulateurs

15. Configurer les tests Cloud Functions

Jusqu'à présent, nous nous sommes concentrés sur l'interface de notre application Web et les règles de sécurité Firestore. Mais cette application utilise également Cloud Functions pour maintenir le panier de l'utilisateur à jour, nous voulons donc également tester ce code.

La suite d'émulateurs facilite le test des fonctions Cloud, même les fonctions qui utilisent Cloud Firestore et d'autres services.

Dans l'éditeur, ouvrez le fichier emulators-codelab/codelab-initial-state/functions/test.js et faites défiler jusqu'au dernier test du fichier. À l'heure actuelle, il est marqué comme en attente :

//  REMOVE .skip FROM THIS LINE
describe.skip("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

Pour activer le test, supprimez .skip , il ressemble donc à ceci :

describe("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

Ensuite, recherchez la variable REAL_FIREBASE_PROJECT_ID en haut du fichier et remplacez-la par votre véritable ID de projet Firebase :

// CHANGE THIS LINE
const REAL_FIREBASE_PROJECT_ID = "changeme";

Si vous avez oublié votre ID de projet, vous pouvez trouver votre ID de projet Firebase dans les paramètres du projet dans la console Firebase :

d6d0429b700d2b21.png

16. Passer en revue les tests de fonctions

Comme ce test valide l'interaction entre Cloud Firestore et Cloud Functions, il implique plus de configuration que les tests des ateliers de programmation précédents. Parcourons ce test et obtenons une idée de ce qu'il attend.

Créer un panier

Cloud Functions s'exécute dans un environnement de serveur approuvé et peut utiliser l'authentification de compte de service utilisée par le SDK Admin . Tout d'abord, vous initialisez une application en utilisant initializeAdminApp au lieu de initializeApp . Ensuite, vous créez un DocumentReference pour le panier auquel nous allons ajouter des articles et initialisez le panier :

it("should sum the cost of their items", async () => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    await aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    ...
  });

Déclencher la fonction

Ensuite, ajoutez des documents à la sous-collection items de notre document de panier afin de déclencher la fonction. Ajoutez deux éléments pour vous assurer que vous testez l'ajout qui se produit dans la fonction.

it("should sum the cost of their items", async () => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    await aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    await aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    await aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });

    ...
    });
  });

Définir les attentes de test

Utilisez onSnapshot() pour enregistrer un écouteur pour toute modification apportée au document du panier. onSnapshot() renvoie une fonction que vous pouvez appeler pour désinscrire l'écouteur.

Pour ce test, ajoutez deux articles qui coûtent ensemble 9,98 $. Ensuite, vérifiez si le panier contient les itemCount et totalPrice attendus. Si c'est le cas, la fonction a fait son travail.

it("should sum the cost of their items", (done) => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });
    
    // Listen for every update to the cart. Every time an item is added to
    // the cart's subcollection of items, the function updates `totalPrice`
    // and `itemCount` attributes on the cart.
    // Returns a function that can be called to unsubscribe the listener.
    await new Promise((resolve) => {
      const unsubscribe = aliceCartRef.onSnapshot(snap => {
        // If the function worked, these will be cart's final attributes.
        const expectedCount = 2;
        const expectedTotal = 9.98;
  
        // When the `itemCount`and `totalPrice` match the expectations for the
        // two items added, the promise resolves, and the test passes.
        if (snap.data().itemCount === expectedCount && snap.data().totalPrice == expectedTotal) {
          // Call the function returned by `onSnapshot` to unsubscribe from updates
          unsubscribe();
          resolve();
        };
      });
    });
   });
 });

17. Exécutez les tests

Il se peut que vous ayez encore les émulateurs en cours d'exécution à partir des tests précédents. Sinon, démarrez les émulateurs. Depuis la ligne de commande, exécutez

$ firebase emulators:start --import=./seed

Ouvrez un nouvel onglet de terminal (laissez les émulateurs en cours d'exécution) et déplacez-vous dans le répertoire des fonctions. Vous pouvez toujours l'avoir ouvert à partir des tests de règles de sécurité.

$ cd functions

Lancez maintenant les tests unitaires, vous devriez voir 5 tests au total :

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (82ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (42ms)

  shopping cart items
    ✓ items can be read by the cart owner (40ms)
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    1) should sum the cost of their items

  4 passing (2s)
  1 failing

Si vous examinez l'échec spécifique, il semble s'agir d'une erreur de délai d'attente. C'est parce que le test attend que la fonction se mette à jour correctement, mais il ne le fait jamais. Maintenant, nous sommes prêts à écrire la fonction pour satisfaire le test.

18. Ecrire une fonction

Pour résoudre ce test, vous devez mettre à jour la fonction dans functions/index.js . Bien qu'une partie de cette fonction soit écrite, elle n'est pas complète. Voici à quoi ressemble actuellement la fonction :

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 125.98;
      let itemCount = 8;
      try {
        
        const cartRef = db.collection("carts").doc(context.params.cartId);

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

La fonction définit correctement la référence du panier, mais au lieu de calculer les valeurs de totalPrice et itemCount , elle les met à jour en valeurs codées en dur.

Récupérer et parcourir le

sous-collection items

Initialisez une nouvelle constante, itemsSnap , pour être la sous-collection items . Ensuite, parcourez tous les documents de la collection.

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }


      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        // ADD LINES FROM HERE
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
        })
        // TO HERE
       
        return cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

Calculer le prix total et le nombre d'articles

Tout d'abord, initialisons les valeurs de totalPrice et itemCount à zéro.

Ensuite, ajoutez la logique à notre bloc d'itération. Tout d'abord, vérifiez que l'article a un prix. Si l'article n'a pas de quantité spécifiée, laissez-le par défaut à 1 . Ensuite, ajoutez la quantité au total cumulé de itemCount . Enfin, ajoutez le prix de l'article multiplié par la quantité au total cumulé de totalPrice :

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      try {
        // CHANGE THESE LINES
        let totalPrice = 0;
        let itemCount = 0;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          // ADD LINES FROM HERE
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = itemData.quantity ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
          // TO HERE
        })

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

Vous pouvez également ajouter une journalisation pour faciliter le débogage des états de réussite et d'erreur :

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 0;
      let itemCount = 0;
      try {
        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = (itemData.quantity) ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
        });

        await cartRef.update({
          totalPrice,
          itemCount
        });

        // OPTIONAL LOGGING HERE
        console.log("Cart total successfully recalculated: ", totalPrice);
      } catch(err) {
        // OPTIONAL LOGGING HERE
        console.warn("update error", err);
      }
    });

19. Refaire les tests

Sur la ligne de commande, assurez-vous que les émulateurs sont toujours en cours d'exécution et relancez les tests. Vous n'avez pas besoin de redémarrer les émulateurs car ils récupèrent automatiquement les modifications apportées aux fonctions. Vous devriez voir tous les tests passer :

$ npm test
> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (306ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (59ms)

  shopping cart items
    ✓ items can be read by the cart owner
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    ✓ should sum the cost of their items (800ms)


  5 passing (1s)

Bon travail!

20. Essayez-le en utilisant l'interface utilisateur Storefront

Pour le test final, revenez à l'application Web ( http://localhost:5000/ ) et ajoutez un article au panier.

69ad26cee520bf24.png

Confirmez que le panier est mis à jour avec le total correct. Fantastique!

résumer

Vous avez parcouru un cas de test complexe entre Cloud Functions pour Firebase et Cloud Firestore. Vous avez écrit une fonction Cloud pour réussir le test. Vous avez également confirmé que la nouvelle fonctionnalité fonctionne dans l'interface utilisateur ! Vous avez fait tout cela localement, en exécutant les émulateurs sur votre propre machine.

Vous avez également créé un client Web qui s'exécute sur les émulateurs locaux, adapté les règles de sécurité pour protéger les données et testé les règles de sécurité à l'aide des émulateurs locaux.

c6a7aeb91fe97a64.gif

,

1. Avant de commencer

Les outils backend sans serveur comme Cloud Firestore et Cloud Functions sont très faciles à utiliser, mais peuvent être difficiles à tester. La suite d'émulateurs locaux Firebase vous permet d'exécuter des versions locales de ces services sur votre ordinateur de développement afin que vous puissiez développer votre application rapidement et en toute sécurité.

Conditions préalables

  • Un éditeur simple tel que Visual Studio Code, Atom ou Sublime Text
  • Node.js 10.0.0 ou supérieur (pour installer Node.js, utilisez nvm , pour vérifier votre version, exécutez node --version )
  • Java 7 ou supérieur (pour installer Java, utilisez ces instructions , pour vérifier votre version, exécutez java -version )

Ce que tu vas faire

Dans cet atelier de programmation, vous allez exécuter et déboguer une application d'achat en ligne simple alimentée par plusieurs services Firebase :

  • Cloud Firestore : une base de données NoSQL sans serveur, évolutive à l'échelle mondiale, avec des fonctionnalités en temps réel.
  • Cloud Functions : un code backend sans serveur qui s'exécute en réponse à des événements ou à des requêtes HTTP.
  • Firebase Authentication : un service d'authentification géré qui s'intègre à d'autres produits Firebase.
  • Firebase Hosting : hébergement rapide et sécurisé pour les applications Web.

Vous connecterez l'application à Emulator Suite pour permettre le développement local.

2589e2f95b74fa88.png

Vous apprendrez également à :

  • Comment connecter votre application à Emulator Suite et comment les différents émulateurs sont connectés.
  • Comment fonctionnent les règles de sécurité Firebase et comment tester les règles de sécurité Firestore par rapport à un émulateur local.
  • Comment écrire une fonction Firebase qui est déclenchée par des événements Firestore et comment écrire des tests d'intégration qui s'exécutent sur Emulator Suite.

2. Configurer

Obtenir le code source

Dans cet atelier de programmation, vous démarrez avec une version de l'exemple The Fire Store qui est presque terminée. La première chose à faire est donc de cloner le code source :

$ git clone https://github.com/firebase/emulators-codelab.git

Ensuite, déplacez-vous dans le répertoire codelab, où vous travaillerez pour le reste de ce codelab :

$ cd emulators-codelab/codelab-initial-state

Maintenant, installez les dépendances pour pouvoir exécuter le code. Si vous utilisez une connexion Internet plus lente, cela peut prendre une minute ou deux :

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

# Move back into the previous directory
$ cd ../

Obtenir la CLI Firebase

La suite d'émulateurs fait partie de la CLI Firebase (interface de ligne de commande) qui peut être installée sur votre machine avec la commande suivante :

$ npm install -g firebase-tools

Ensuite, confirmez que vous disposez de la dernière version de la CLI. Ce laboratoire de programmation devrait fonctionner avec la version 9.0.0 ou ultérieure, mais les versions ultérieures incluent davantage de corrections de bogues.

$ firebase --version
9.6.0

Connectez-vous à votre projet Firebase

Si vous n'avez pas de projet Firebase, dans la console Firebase , créez un nouveau projet Firebase. Notez l'ID de projet que vous choisissez, vous en aurez besoin plus tard.

Nous devons maintenant connecter ce code à votre projet Firebase. Exécutez d'abord la commande suivante pour vous connecter à la CLI Firebase :

$ firebase login

Exécutez ensuite la commande suivante pour créer un alias de projet. Remplacez $YOUR_PROJECT_ID par l'ID de votre projet Firebase.

$ firebase use $YOUR_PROJECT_ID

Vous êtes maintenant prêt à exécuter l'application !

3. Exécutez les émulateurs

Dans cette section, vous exécuterez l'application localement. Cela signifie qu'il est temps de démarrer Emulator Suite.

Démarrer les émulateurs

Depuis le répertoire source de l'atelier de programmation, exécutez la commande suivante pour démarrer les émulateurs :

$ firebase emulators:start --import=./seed

Vous devriez voir une sortie comme celle-ci :

$ firebase emulators:start --import=./seed
i  emulators: Starting emulators: auth, functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, pubsub
i  firestore: Importing data from /Users/samstern/Projects/emulators-codelab/codelab-initial-state/seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/samstern/Projects/emulators-codelab/codelab-initial-state/functions" for Cloud Functions...
✔  functions[calculateCart]: firestore function initialized.

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://localhost:4000                │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ localhost:5001 │ http://localhost:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ localhost:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at localhost:4400
  Other reserved ports: 4500

Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.

Une fois que vous voyez le message Tous les émulateurs ont démarré , l'application est prête à être utilisée.

Connectez l'application Web aux émulateurs

Sur la base du tableau des journaux, nous pouvons voir que l'émulateur Cloud Firestore écoute sur le port 8080 et que l'émulateur d'authentification écoute sur le port 9099 .

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ localhost:5001 │ http://localhost:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ localhost:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘

Connectons votre code frontal à l'émulateur, plutôt qu'à la production. Ouvrez le fichier public/js/homepage.js et recherchez la fonction onDocumentReady . Nous pouvons voir que le code accède aux instances standards Firestore et Auth :

public/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

Mettons à jour les objets db et auth pour qu'ils pointent vers les émulateurs locaux :

public/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

  // ADD THESE LINES
  if (location.hostname === "localhost") {
    console.log("localhost detected!");
    auth.useEmulator("http://localhost:9099");
    db.useEmulator("localhost", 8080);
  }

Désormais, lorsque l'application s'exécute sur localhost (servi par l'émulateur d'hébergement), le client Firestore pointe également vers l'émulateur local plutôt que vers une base de données de production.

Ouvrez l'interface utilisateur de l'émulateur

Dans votre navigateur Web, accédez à http://localhost:4000/ . Vous devriez voir l'interface utilisateur d'Emulator Suite.

Écran d'accueil de l'interface utilisateur des émulateurs

Cliquez pour voir l'interface utilisateur de l'émulateur Firestore. La collection items contient déjà des données en raison des données importées avec l'indicateur --import .

4ef88d0148405d36.png

4. Exécutez l'application

Ouvrez l'application

Dans votre navigateur Web, accédez à http://localhost:5000 et vous devriez voir le Fire Store s'exécuter localement sur votre machine !

939f87946bac2ee4.png

Utilisez l'application

Choisissez un article sur la page d'accueil et cliquez sur Ajouter au panier . Malheureusement, vous rencontrerez l'erreur suivante :

a11bd59933a8e885.png

Corrigeons ce bug ! Comme tout fonctionne dans les émulateurs, nous pouvons expérimenter sans nous soucier d'affecter les données réelles.

5. Déboguer l'application

Trouvez le bogue

Ok, regardons dans la console développeur de Chrome. Appuyez sur Control+Shift+J (Windows, Linux, Chrome OS) ou Command+Option+J (Mac) pour voir l'erreur sur la console :

74c45df55291dab1.png

Il semble qu'il y ait eu une erreur dans la méthode addToCart , examinons cela. Où essayons-nous d'accéder à quelque chose appelé uid dans cette méthode et pourquoi serait-il null ? À l'heure actuelle, la méthode ressemble à ceci dans public/js/homepage.js :

public/js/homepage.js

  addToCart(id, itemData) {
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

Ah ! Nous ne sommes pas connectés à l'application. Selon les documents Firebase Authentication , lorsque nous ne sommes pas connectés, auth.currentUser est null . Ajoutons une vérification pour cela :

public/js/homepage.js

  addToCart(id, itemData) {
    // ADD THESE LINES
    if (this.auth.currentUser === null) {
      this.showError("You must be signed in!");
      return;
    }

    // ...
  }

Testez l'application

Maintenant, actualisez la page, puis cliquez sur Ajouter au panier . Vous devriez obtenir une erreur plus agréable cette fois :

c65f6c05588133f7.png

Mais si vous cliquez sur Connexion dans la barre d'outils supérieure, puis cliquez à nouveau sur Ajouter au panier , vous verrez que le panier est mis à jour.

Cependant, il semble que les chiffres ne soient pas du tout corrects :

239f26f02f959eef.png

Ne vous inquiétez pas, nous allons bientôt corriger ce bug. Tout d'abord, examinons en profondeur ce qui s'est réellement passé lorsque vous avez ajouté un article à votre panier.

6. Déclencheurs de fonctions locales

Cliquer sur Ajouter au panier lance une chaîne d'événements impliquant plusieurs émulateurs. Dans les journaux de la CLI Firebase, vous devriez voir quelque chose comme les messages suivants après avoir ajouté un article à votre panier :

i  functions: Beginning execution of "calculateCart"
i  functions: Finished "calculateCart" in ~1s

Quatre événements clés se sont produits pour produire ces journaux et la mise à jour de l'interface utilisateur que vous avez observée :

68c9323f2ad10f7a.png

1) Écriture Firestore - Client

Un nouveau document est ajouté à la collection Firestore /carts/{cartId}/items/{itemId}/ . Vous pouvez voir ce code dans la fonction addToCart dans public/js/homepage.js :

public/js/homepage.js

  addToCart(id, itemData) {
    // ...
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

2) Fonction cloud déclenchée

La fonction Cloud calculateCart écoute tous les événements d'écriture (création, mise à jour ou suppression) qui se produisent sur les éléments du panier à l'aide du déclencheur onWrite , que vous pouvez voir dans functions/index.js :

fonctions/index.js

exports.calculateCart = functions.firestore
    .document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    }
);

3) Écriture Firestore - Administrateur

La fonction calculateCart lit tous les articles du panier et additionne la quantité et le prix total, puis elle met à jour le document "cart" avec les nouveaux totaux (voir cartRef.update(...) ci-dessus).

4) Firestore Lire - Client

L'interface Web est abonnée pour recevoir des mises à jour sur les modifications apportées au panier. Il obtient une mise à jour en temps réel après que la fonction Cloud a écrit les nouveaux totaux et mis à jour l'interface utilisateur, comme vous pouvez le voir dans public/js/homepage.js :

public/js/homepage.js

this.cartUnsub = cartRef.onSnapshot(cart => {
   // The cart document was changed, update the UI
   // ...
});

résumer

Bon travail! Vous venez de configurer une application entièrement locale qui utilise trois émulateurs Firebase différents pour des tests entièrement locaux.

db82eef1706c9058.gif

Mais attendez, il y a plus ! Dans la section suivante, vous apprendrez :

  • Comment écrire des tests unitaires qui utilisent les émulateurs Firebase.
  • Comment utiliser les émulateurs Firebase pour déboguer vos règles de sécurité.

7. Créez des règles de sécurité adaptées à votre application

Notre application Web lit et écrit des données, mais jusqu'à présent, nous ne nous sommes pas du tout inquiétés de la sécurité. Cloud Firestore utilise un système appelé "Règles de sécurité" pour déclarer qui a accès aux données en lecture et en écriture. La suite d'émulateurs est un excellent moyen de prototyper ces règles.

Dans l'éditeur, ouvrez le fichier emulators-codelab/codelab-initial-state/firestore.rules . Vous verrez que nous avons trois sections principales dans nos règles :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

À l'heure actuelle, n'importe qui peut lire et écrire des données dans notre base de données ! Nous voulons nous assurer que seules les opérations valides passent et que nous ne divulguons aucune information sensible.

Au cours de cet atelier de programmation, conformément au principe du moindre privilège, nous verrouillerons tous les documents et ajouterons progressivement l'accès jusqu'à ce que tous les utilisateurs aient tous les accès dont ils ont besoin, mais pas plus. Mettons à jour les deux premières règles pour refuser l'accès en définissant la condition sur false :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

8. Exécutez les émulateurs et les tests

Démarrer les émulateurs

Sur la ligne de commande, assurez-vous que vous êtes dans emulators-codelab/codelab-initial-state/ . Vous pouvez toujours avoir les émulateurs en cours d'exécution à partir des étapes précédentes. Sinon, redémarrez les émulateurs :

$ firebase emulators:start --import=./seed

Une fois que les émulateurs sont en cours d'exécution, vous pouvez exécuter des tests localement sur eux.

Exécutez les tests

Sur la ligne de commande dans un nouvel onglet de terminal depuis le répertoire emulators-codelab/codelab-initial-state/

Déplacez-vous d'abord dans le répertoire functions (nous y resterons pour le reste du codelab):

$ cd functions

Exécutez maintenant les tests moka dans le répertoire des fonctions et faites défiler vers le haut de la sortie :

# Run the tests
$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    1) can be created and updated by the cart owner
    2) can be read only by the cart owner

  shopping cart items
    3) can be read only by the cart owner
    4) can be added only by the cart owner

  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items


  0 passing (364ms)
  1 pending
  4 failing

En ce moment, nous avons quatre échecs. Au fur et à mesure que vous créez le fichier de règles, vous pouvez mesurer les progrès en observant la réussite de plusieurs tests.

9. Accès sécurisé au panier

Les deux premiers échecs sont les tests "panier" qui testent que :

  • Les utilisateurs peuvent uniquement créer et mettre à jour leurs propres paniers
  • Les utilisateurs ne peuvent lire que leurs propres paniers

fonctions/test.js

  it('can be created and updated by the cart owner', async () => {
    // Alice can create her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Bob can't create Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Alice can update her own cart with a new total
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").update({
      total: 1
    }));

    // Bob can't update Alice's cart with a new total
    await firebase.assertFails(bobDb.doc("carts/alicesCart").update({
      total: 1
    }));
  });

  it("can be read only by the cart owner", async () => {
    // Setup: Create Alice's cart as admin
    await admin.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    });

    // Alice can read her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").get());

    // Bob can't read Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").get());
  });

Faisons passer ces tests. Dans l'éditeur, ouvrez le fichier de règles de sécurité, firestore.rules , et mettez à jour les instructions dans match /carts/{cartID} :

firestore.rules

rules_version = '2';
service cloud.firestore {
    // UPDATE THESE LINES
    match /carts/{cartID} {
      allow create: if request.auth.uid == request.resource.data.ownerUID;
      allow read, update, delete: if request.auth.uid == resource.data.ownerUID;
    }

    // ...
  }
}

Ces règles autorisent désormais uniquement l'accès en lecture et en écriture au propriétaire du panier.

To verify incoming data and user's authentication, we use two objects that are available in the context of every rule:

  • The request object contains data and metadata about the operation that is being attempted.
  • If a Firebase project is using Firebase Authentication , the request.auth object describes the user who is making the request.

10. Test cart access

The Emulator Suite automatically updates the rules whenever firestore.rules is saved. You can confirm that the emulator has the updated the rules by looking in the tab running the emulator for the message Rules updated :

5680da418b420226.png

Rerun the tests, and check that the first two tests now pass:

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    1) can be read only by the cart owner
    2) can be added only by the cart owner

  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items

  2 passing (482ms)
  1 pending
  2 failing

Good job! You have now secured access to shopping carts. Let's move on to the next failing test.

11. Check the "Add to Cart" flow in the UI

Right now, although cart owners read and write to their cart, they can't read or write individual items in their cart. That's because while owners have access to the cart document, they don't have access to the cart's items subcollection .

This is a broken state for users.

Return to the web UI, which is running on http://localhost:5000, and try to add something to your cart. You get a Permission Denied error, visible from the debug console, because we haven't yet granted users access to created documents in the items subcollection.

12. Allow cart items access

These two tests confirm that users can only add items to or read items from their own cart:

  it("can be read only by the cart owner", async () => {
    // Alice can read items in her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/milk").get());

    // Bob can't read items in alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/milk").get())
  });

  it("can be added only by the cart owner",  async () => {
    // Alice can add an item to her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));

    // Bob can't add an item to alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));
  });

So we can write a rule that allows access if the current user has the same UID as the ownerUID on the cart document. Since there's no need to specify different rules for create, update, delete , you can use a write rule, which applies to all requests that modify data.

Update the rule for the documents in the items subcollection. The get in the conditional is reading a value from Firestore–in this case, the ownerUID on the cart document.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ...

    // UPDATE THESE LINES
    match /carts/{cartID}/items/{itemID} {
      allow read, write: if get(/databases/$(database)/documents/carts/$(cartID)).data.ownerUID == request.auth.uid;
    }

    // ...
  }
}

13. Test cart items access

Now we can rerun the test. Scroll to the top of the output and check that more tests pass:

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    ✓ can be read only by the cart owner (111ms)
    ✓ can be added only by the cart owner


  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items


  4 passing (401ms)
  1 pending

Nice! Now all of our tests pass. We have one pending test, but we'll get to that in a few steps.

14. Check the "add to cart" flow again

Return to the web front end ( http://localhost:5000 ) and add an item to the cart. This is an important step to confirm that our tests and rules match the functionality required by the client. (Remember that the last time we tried out the UI users were unable to add items to their cart!)

69ad26cee520bf24.png

The client automatically reloads the rules when the firestore.rules is saved. So, try adding something to the cart.

Recap

Nice work! You just improved the security of your app, an essential step for getting it ready for production! If this were a production app, we could add these tests to our continuous integration pipeline. This would give us confidence going forward that our shopping cart data will have these access controls, even if others are modifying the rules.

ba5440b193e75967.gif

But wait, there's more!

if you continue on you'll learn:

  • How to write a function triggered by a Firestore event
  • How to create tests that work across multiple emulators

15. Set up Cloud Functions tests

So far we've focused on the frontend of our web app and the Firestore Security Rules. But this app also uses Cloud Functions to keep the user's cart up to date, so we want to test that code as well.

The Emulator Suite makes it so easy to test Cloud Functions, even functions that use Cloud Firestore and other services.

In the editor, open the emulators-codelab/codelab-initial-state/functions/test.js file and scroll to the last test in the file. Right now, it's marked as pending:

//  REMOVE .skip FROM THIS LINE
describe.skip("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

To enable the test, remove .skip , so it looks like this:

describe("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

Next, find the REAL_FIREBASE_PROJECT_ID variable at the top of the file and change it to your real Firebase Project ID.:

// CHANGE THIS LINE
const REAL_FIREBASE_PROJECT_ID = "changeme";

If you forgot your project ID you can find your Firebase Project ID in the Project Settings in the Firebase Console:

d6d0429b700d2b21.png

16. Walk through Functions tests

Because this test validates the interaction between Cloud Firestore and Cloud Functions, it involves more setup than the tests in the previous codelabs. Let's walk through this test and get an idea of what it expects.

Create a cart

Cloud Functions run in a trusted server environment and can use the service account authentication used by the Admin SDK . First, you initialize an app using initializeAdminApp instead of initializeApp . Then, you create a DocumentReference for the cart we'll be adding items to and initialize the cart:

it("should sum the cost of their items", async () => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    await aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    ...
  });

Trigger the function

Then, add documents to the items subcollection of our cart document in order to trigger the function. Add two items to make sure you're testing the addition that happens in the function.

it("should sum the cost of their items", async () => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    await aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    await aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    await aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });

    ...
    });
  });

Set test expectations

Use onSnapshot() to register a listener for any changes on the cart document. onSnapshot() returns a function that you can call to unregister the listener.

For this test, add two items that together cost $9.98. Then, check if the cart has the expected itemCount and totalPrice . If so, then the function did its job.

it("should sum the cost of their items", (done) => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });
    
    // Listen for every update to the cart. Every time an item is added to
    // the cart's subcollection of items, the function updates `totalPrice`
    // and `itemCount` attributes on the cart.
    // Returns a function that can be called to unsubscribe the listener.
    await new Promise((resolve) => {
      const unsubscribe = aliceCartRef.onSnapshot(snap => {
        // If the function worked, these will be cart's final attributes.
        const expectedCount = 2;
        const expectedTotal = 9.98;
  
        // When the `itemCount`and `totalPrice` match the expectations for the
        // two items added, the promise resolves, and the test passes.
        if (snap.data().itemCount === expectedCount && snap.data().totalPrice == expectedTotal) {
          // Call the function returned by `onSnapshot` to unsubscribe from updates
          unsubscribe();
          resolve();
        };
      });
    });
   });
 });

17. Run the tests

You might still have the emulators running from the previous tests. If not, start the emulators. From the command line, run

$ firebase emulators:start --import=./seed

Open a new terminal tab (leave the emulators running) and move into the functions directory. You might still have this open from the security rules tests.

$ cd functions

Now run the unit tests, you should see 5 total tests:

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (82ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (42ms)

  shopping cart items
    ✓ items can be read by the cart owner (40ms)
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    1) should sum the cost of their items

  4 passing (2s)
  1 failing

If you look at the specific failure, it appears to be a timeout error. This is because the test is waiting for the function to correctly update, but it never does. Now, we're ready to write the function to satisfy the test.

18. Write a function

To fix this test, you need to update the function in functions/index.js . Although some of this function is written, it's not complete. This is how the function currently looks:

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 125.98;
      let itemCount = 8;
      try {
        
        const cartRef = db.collection("carts").doc(context.params.cartId);

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

The function is correctly setting the cart reference, but then instead of calculating the values of totalPrice and itemCount , it updates them to hardcoded ones.

Fetch and iterate through the

items subcollection

Initialize a new constant, itemsSnap , to be the items subcollection. Then, iterate through all the documents in the collection.

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }


      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        // ADD LINES FROM HERE
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
        })
        // TO HERE
       
        return cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

Calculate totalPrice and itemCount

First, let's initialize the values of totalPrice and itemCount to zero.

Then, add the logic to our iteration block. First, check that the item has a price. If the item doesn't have a quantity specified, let it default to 1 . Then, add the quantity to the running total of itemCount . Finally, add the item's price multiplied by the quantity to the running total of totalPrice :

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      try {
        // CHANGE THESE LINES
        let totalPrice = 0;
        let itemCount = 0;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          // ADD LINES FROM HERE
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = itemData.quantity ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
          // TO HERE
        })

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

You can also add logging to help debug success and error states:

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 0;
      let itemCount = 0;
      try {
        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = (itemData.quantity) ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
        });

        await cartRef.update({
          totalPrice,
          itemCount
        });

        // OPTIONAL LOGGING HERE
        console.log("Cart total successfully recalculated: ", totalPrice);
      } catch(err) {
        // OPTIONAL LOGGING HERE
        console.warn("update error", err);
      }
    });

19. Rerun tests

On the command line, make sure the emulators are still running and re-run the tests. You don't need to restart the emulators because they pick up changes to the functions automatically. You should see all the tests pass:

$ npm test
> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (306ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (59ms)

  shopping cart items
    ✓ items can be read by the cart owner
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    ✓ should sum the cost of their items (800ms)


  5 passing (1s)

Good job!

20. Try it out using the Storefront UI

For the final test, return to the web app ( http://localhost:5000/ ) and add an item to the cart.

69ad26cee520bf24.png

Confirm that the cart updates with the correct total. Fantastic!

Recap

You've walked through a complex test case between Cloud Functions for Firebase and Cloud Firestore. You wrote a Cloud Function to make the test pass. You also confirmed the new functionality is working in the UI! You did all this locally, running the emulators on your own machine.

You've also created a web client that's running against the local emulators, tailored security rules to protect the data, and tested the security rules using the local emulators.

c6a7aeb91fe97a64.gif