تطوير محلي باستخدام "حزمة محاكي Firebase"

1. قبل البدء

إنّ أدوات الخلفية التي لا تتطلّب خادمًا، مثل Cloud Firestore وCloud Functions، سهلة الاستخدام للغاية، ولكن قد يكون من الصعب اختبارها. تتيح لك "مجموعة أدوات المحاكاة المحلية لـ Firebase" تشغيل نُسخ محلية من هذه الخدمات على جهاز التطوير لتتمكّن من تطوير تطبيقك بسرعة وأمان.

المتطلبات الأساسية

  • محرر بسيط مثل Visual Studio Code أو Atom أو Sublime Text
  • ‫Node.js 10.0.0 أو إصدار أحدث (لتثبيت Node.js، استخدِم nvm، وللتحقّق من الإصدار، شغِّل node --version)
  • ‫Java 7 أو إصدار أحدث (لتثبيت Java، اتّبِع هذه التعليمات، وللتحقّق من الإصدار، شغِّل java -version)

المهام التي ستنفّذها

في هذا الدرس التطبيقي حول الترميز، ستشغّل تطبيقًا بسيطًا للتسوّق على الإنترنت وتصحّح أخطاءه، وهو تطبيق يستند إلى خدمات Firebase المتعدّدة:

  • ‫Cloud Firestore: هي قاعدة بيانات NoSQL بدون خادم وقابلة للتوسّع على مستوى العالم وتتضمّن إمكانات في الوقت الفعلي.
  • دوال السحابة الإلكترونية: هي رمز خلفي بدون خادم يتم تشغيله استجابةً للأحداث أو طلبات HTTP.
  • مصادقة Firebase: هي خدمة مصادقة مُدارة تتكامل مع منتجات Firebase الأخرى.
  • استضافة Firebase: استضافة سريعة وآمنة لتطبيقات الويب

ستربط التطبيق بمجموعة أدوات المحاكاة لتفعيل عملية التطوير على الجهاز.

2589e2f95b74fa88.png

ستتعرّف أيضًا على كيفية:

  • كيفية ربط تطبيقك بـ "مجموعة أدوات المحاكاة" وكيفية ربط أدوات المحاكاة المختلفة
  • طريقة عمل "قواعد أمان Firebase" وكيفية اختبار "قواعد أمان Firestore" باستخدام محاكي محلي
  • كيفية كتابة دالة Firebase يتم تشغيلها من خلال أحداث Firestore وكيفية كتابة اختبارات التكامل التي يتم تشغيلها في مجموعة أدوات المحاكاة.

2. إعداد

الحصول على الرمز المصدر

في هذا الدرس التطبيقي حول الترميز، ستبدأ بنسخة من نموذج تطبيق "متجر Fire" شبه مكتملة، لذا فإنّ أول ما عليك فعله هو استنساخ الرمز المصدر:

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

بعد ذلك، انتقِل إلى دليل الدرس التطبيقي حول الترميز، حيث ستعمل على الجزء المتبقي من هذا الدرس:

$ cd emulators-codelab/codelab-initial-state

الآن، ثبِّت التبعيات لتتمكّن من تشغيل الرمز البرمجي. إذا كان اتصالك بالإنترنت بطيئًا، قد يستغرق ذلك دقيقة أو دقيقتين:

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

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

الحصول على Firebase CLI

تُعدّ "مجموعة أدوات المحاكاة" جزءًا من Firebase CLI (واجهة سطر الأوامر) التي يمكن تثبيتها على جهازك باستخدام الأمر التالي:

$ npm install -g firebase-tools

بعد ذلك، تأكَّد من تثبيت أحدث إصدار من واجهة سطر الأوامر. من المفترض أن يعمل هذا الدرس التطبيقي حول الترميز مع الإصدار 9.0.0 أو الإصدارات الأحدث، ولكن الإصدارات اللاحقة تتضمّن المزيد من إصلاحات الأخطاء.

$ firebase --version
9.6.0

الربط بمشروع Firebase

إنشاء مشروع Firebase

  1. سجِّل الدخول إلى وحدة تحكّم Firebase باستخدام حساب Google.
  2. انقر على الزر لإنشاء مشروع جديد، ثم أدخِل اسم المشروع (على سبيل المثال، Emulators Codelab).
  3. انقر على متابعة.
  4. راجِع بنود Firebase واقبلها إذا طُلب منك ذلك، ثمّ انقر على متابعة.
  5. (اختياري) فعِّل ميزة "المساعدة بالذكاء الاصطناعي" في وحدة تحكّم Firebase (المعروفة باسم "Gemini في Firebase").
  6. في هذا الدرس التطبيقي حول الترميز، لا تحتاج إلى "إحصاءات Google"، لذا أوقِف خيار "إحصاءات Google".
  7. انقر على إنشاء مشروع، وانتظِر حتى يتم توفير مشروعك، ثم انقر على متابعة.

ربط الرمز بمشروع Firebase

الآن، علينا ربط هذا الرمز بمشروعك على Firebase. أولاً، نفِّذ الأمر التالي لتسجيل الدخول إلى واجهة سطر أوامر Firebase:

$ firebase login

بعد ذلك، شغِّل الأمر التالي لإنشاء اسم مستعار للمشروع. استبدِل $YOUR_PROJECT_ID برقم تعريف مشروعك على Firebase.

$ firebase use $YOUR_PROJECT_ID

أنت الآن جاهز لتشغيل التطبيق.

3- تشغيل المحاكيات

في هذا القسم، ستشغّل التطبيق على جهازك. هذا يعني أنّ الوقت قد حان لتشغيل "مجموعة أدوات المحاكاة".

بدء تشغيل المحاكيات

من داخل دليل ملفات المصدر للدرس التطبيقي حول الترميز، شغِّل الأمر التالي لبدء المحاكيات:

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

من المفترض أن تظهر لك نتيجة مثل هذه:

$ 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://127.0.0.1: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://127.0.0.1:4000                │
└─────────────────────────────────────────────────────────────┘

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

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

بعد ظهور رسالة تم بدء تشغيل جميع المحاكيات، يصبح التطبيق جاهزًا للاستخدام.

ربط تطبيق الويب بالمحاكيات

استنادًا إلى الجدول في السجلّات، يمكننا ملاحظة أنّ محاكي Cloud Firestore يستمع إلى المنفذ 8080 وأنّ محاكي "المصادقة" يستمع إلى المنفذ 9099.

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

لنربط رمز الواجهة الأمامية بالمحاكي بدلاً من بيئة الإنتاج. افتح الملف public/js/homepage.js وابحث عن الدالة onDocumentReady. يمكننا أن نرى أنّ الرمز البرمجي يصل إلى مثيلات Firestore وAuth العادية:

public/js/homepage.js

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

لنعدّل العنصرَين db وauth للإشارة إلى المحاكيات المحلية:

public/js/homepage.js

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

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

الآن، عندما يكون التطبيق قيد التشغيل على جهازك المحلي (الذي يوفّره محاكي الاستضافة)، يشير عميل Firestore أيضًا إلى المحاكي المحلي بدلاً من قاعدة بيانات الإنتاج.

فتح EmulatorUI

في متصفّح الويب، انتقِل إلى http://127.0.0.1:4000/‎. من المفترض أن تظهر لك واجهة مستخدم "مجموعة أدوات المحاكاة".

الشاشة الرئيسية لواجهة مستخدم المحاكيات

انقر للاطّلاع على واجهة مستخدم محاكي Firestore. تحتوي مجموعة items على بيانات بسبب البيانات التي تم استيرادها باستخدام العلامة --import.

4ef88d0148405d36.png

4. تشغيل التطبيق

فتح التطبيق

في متصفح الويب، انتقِل إلى http://127.0.0.1:5000 ومن المفترض أن يظهر لك تطبيق The Fire Store قيد التشغيل محليًا على جهازك.

939f87946bac2ee4.png

استخدام التطبيق

اختَر سلعة من الصفحة الرئيسية وانقر على إضافة إلى سلة التسوّق. للأسف، سيظهر لك الخطأ التالي:

a11bd59933a8e885.png

لنصلح هذا الخطأ! بما أنّ كل شيء يعمل في المحاكيات، يمكننا إجراء التجارب بدون القلق بشأن التأثير في البيانات الحقيقية.

5- تصحيح أخطاء التطبيق

العثور على الخطأ

حسنًا، لنلقِ نظرة على وحدة تحكّم مطوّري برامج Chrome. اضغط على Control+Shift+J (في نظام التشغيل Windows أو Linux أو ChromeOS) أو Command+Option+J (في نظام التشغيل Mac) للاطّلاع على الخطأ في وحدة التحكّم:

74c45df55291dab1.png

يبدو أنّه حدث خطأ في طريقة "addToCart"، لنلقِ نظرة على ذلك. أين نحاول الوصول إلى عنصر باسم uid في هذه الطريقة، ولماذا تكون قيمته null؟ في الوقت الحالي، تظهر الطريقة على النحو التالي في 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);
  }

آها! لم نسجّل الدخول إلى التطبيق. وفقًا لمستندات "مصادقة Firebase"، عندما لا نسجّل الدخول، تكون قيمة auth.currentUser هي null. لنضِف عملية تحقّق من ذلك:

public/js/homepage.js

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

    // ...
  }

اختبار التطبيق

الآن، أعِد تحميل الصفحة ثم انقر على إضافة إلى سلة التسوّق. من المفترض أن يظهر لك خطأ أفضل هذه المرة:

c65f6c05588133f7.png

ولكن إذا نقرت على تسجيل الدخول في شريط الأدوات العلوي ثم نقرت على إضافة إلى سلة التسوق مرة أخرى، فسترى أنه تم تحديث سلة التسوق.

ومع ذلك، لا يبدو أنّ الأرقام صحيحة على الإطلاق:

239f26f02f959eef.png

لا داعي للقلق، سنصلح هذا الخطأ قريبًا. أولاً، لنتعرّف بالتفصيل على ما حدث فعليًا عند إضافة سلعة إلى سلة التسوّق.

6. مشغّلات الدوال المحلية

يؤدي النقر على إضافة إلى سلة التسوق إلى بدء سلسلة من الأحداث التي تتضمن محاكيات متعددة. في سجلّات Firebase CLI، من المفترض أن تظهر لك رسائل مشابهة لما يلي بعد إضافة سلعة إلى سلة التسوّق:

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

كانت هناك أربعة أحداث رئيسية حدثت لإنتاج هذه السجلات وتحديث واجهة المستخدم الذي لاحظته:

68c9323f2ad10f7a.png

‫1) Firestore Write - Client

تمت إضافة مستند جديد إلى مجموعة Firestore‏ /carts/{cartId}/items/{itemId}/. يمكنك الاطّلاع على هذا الرمز في الدالة addToCart داخل 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) تفعيل Cloud Function

تستمع دالة Cloud Function‏ calculateCart إلى أي أحداث كتابة (إنشاء أو تعديل أو حذف) تحدث لعناصر سلة التسوّق باستخدام عامل التشغيل onWrite، والذي يمكنك الاطّلاع عليه في functions/index.js:

functions/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) Firestore Write - مشرف

تقرأ الدالة calculateCart جميع السلع في سلة التسوّق وتجمع إجمالي الكمية والسعر، ثم تعدّل مستند "سلة التسوّق" بالإجماليات الجديدة (راجِع cartRef.update(...) أعلاه).

‫4) قراءة Firestore - العميل

يتم الاشتراك في واجهة الويب الأمامية لتلقّي آخر الأخبار حول التغييرات في سلة التسوّق. ويتم تعديلها في الوقت الفعلي بعد أن تكتب دالة Cloud Function المجاميع الجديدة وتعدّل واجهة المستخدم، كما هو موضّح في public/js/homepage.js:

public/js/homepage.js

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

ملخّص

أحسنت. لقد أعددت للتو تطبيقًا محليًا بالكامل يستخدم ثلاثة محاكيات Firebase مختلفة للاختبار المحلي بالكامل.

db82eef1706c9058.gif

انتظر، فهناك المزيد. في القسم التالي، ستتعرّف على ما يلي:

  • كيفية كتابة اختبارات الوحدة التي تستخدم "محاكيات Firebase"
  • كيفية استخدام محاكيات Firebase لتصحيح أخطاء قواعد الأمان

7. إنشاء قواعد أمان مخصّصة لتطبيقك

يقرأ تطبيق الويب الخاص بنا البيانات ويكتبها، ولكن حتى الآن لم نقلق بشأن الأمان على الإطلاق. يستخدم Cloud Firestore نظامًا يُسمى "قواعد الأمان" لتحديد المستخدمين الذين لديهم إذن بالوصول إلى البيانات لقراءتها وكتابتها. تُعدّ "مجموعة أدوات المحاكاة" طريقة رائعة لإنشاء نماذج أولية لهذه القواعد.

في المحرِّر، افتح الملف emulators-codelab/codelab-initial-state/firestore.rules. ستلاحظ أنّ هناك ثلاثة أقسام رئيسية في قواعدنا:

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;
    }
  }
}

في الوقت الحالي، يمكن لأي شخص قراءة البيانات وكتابتها في قاعدة البيانات. نريد التأكّد من أنّ العمليات الصالحة فقط هي التي يتم تنفيذها وأنّنا لا نسرّب أي معلومات حساسة.

خلال هذا الدرس التطبيقي حول الترميز، سنقفل جميع المستندات ونضيف إمكانية الوصول تدريجيًا إلى أن يحصل جميع المستخدمين على كل إمكانية الوصول التي يحتاجون إليها، ولكن ليس أكثر، وذلك باتّباع مبدأ الحدّ الأدنى من الأذونات المميّزة. لنعدّل القاعدتَين الأوليَين لرفض الوصول من خلال ضبط الشرط على 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. تشغيل المحاكيات والاختبارات

بدء تشغيل المحاكيات

في سطر الأوامر، تأكَّد من أنّك في emulators-codelab/codelab-initial-state/. قد لا تزال المحاكيات قيد التشغيل من الخطوات السابقة. وإذا لم يكن الأمر كذلك، ابدأ تشغيل المحاكيات مرة أخرى:

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

بعد تشغيل المحاكيات، يمكنك إجراء اختبارات محلية عليها.

إجراء الاختبارات

في سطر الأوامر في علامة تبويب جديدة في الوحدة الطرفية من الدليل emulators-codelab/codelab-initial-state/

انتقِل أولاً إلى دليل الدوال (سنبقى هنا لبقية الدرس التطبيقي حول الترميز):

$ cd functions

الآن، شغِّل اختبارات mocha في دليل الدوال، وانتقِل إلى أعلى الناتج:

# 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

في الوقت الحالي، لدينا أربع حالات فشل. أثناء إنشاء ملف القواعد، يمكنك قياس مستوى التقدّم من خلال مشاهدة المزيد من الاختبارات التي تم اجتيازها.

9. الوصول الآمن إلى سلة التسوّق

أول حالتي فشل هما اختبارا "سلّة التسوّق" اللذان يختبران ما يلي:

  • يمكن للمستخدمين إنشاء سلال التسوّق الخاصة بهم وتعديلها فقط
  • يمكن للمستخدمين قراءة سلال التسوّق الخاصة بهم فقط

functions/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());
  });

لنجعل هذه الاختبارات تنجح. في المحرِّر، افتح ملف قواعد الأمان firestore.rules وعدِّل العبارات ضمن 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;
    }

    // ...
  }
}

تسمح هذه القواعد الآن لمالك سلة التسوّق فقط بالوصول إلى المحتوى وقراءته وكتابته.

للتحقّق من البيانات الواردة ومصادقة المستخدم، نستخدم عنصرَين متاحَين في سياق كل قاعدة:

  • يحتوي العنصر request على بيانات وبيانات وصفية حول العملية التي تتم محاولة تنفيذها.
  • إذا كان مشروع Firebase يستخدم مصادقة Firebase، يصف العنصر request.auth المستخدم الذي يرسل الطلب.

10. اختبار الوصول إلى سلة التسوّق

تعدّل "مجموعة أدوات المحاكاة" القواعد تلقائيًا عند حفظ firestore.rules. يمكنك التأكّد من أنّ المحاكي قد عدّل القواعد من خلال الاطّلاع على علامة التبويب التي تشغّل المحاكي بحثًا عن الرسالة Rules updated:

5680da418b420226.png

أعِد تشغيل الاختبارات، وتأكَّد من اجتياز الاختبارَين الأولَين الآن:

$ 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

أحسنت! لقد أمّنت الآن الوصول إلى سلال التسوّق. لننتقل إلى الاختبار التالي الذي لم ينجح.

11. التحقّق من عملية "الإضافة إلى سلة التسوّق" في واجهة المستخدم

في الوقت الحالي، على الرغم من أنّ مالكي سلة التسوّق يمكنهم قراءة سلة التسوّق والكتابة فيها، لا يمكنهم قراءة السلع الفردية في سلة التسوّق أو كتابتها. ويرجع ذلك إلى أنّه على الرغم من أنّ المالكين لديهم إذن بالوصول إلى مستند سلة التسوّق، ليس لديهم إذن بالوصول إلى المجموعة الفرعية للسلع في سلة التسوّق.

وهذه حالة معطّلة للمستخدمين.

ارجع إلى واجهة المستخدم على الويب التي تعمل على http://127.0.0.1:5000, وحاوِل إضافة منتج إلى سلة التسوّق. يظهر لك خطأ Permission Denied في وحدة تحكّم تصحيح الأخطاء، لأنّنا لم نمنح المستخدمين بعد إذن الوصول إلى المستندات التي تم إنشاؤها في المجموعة الفرعية items.

12. السماح بالوصول إلى السلع في سلة التسوّق

يؤكّد هذان الاختباران أنّه يمكن للمستخدمين فقط إضافة سلع إلى سلة التسوّق الخاصة بهم أو قراءة السلع منها:

  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
    }));
  });

لذا يمكننا كتابة قاعدة تسمح بالوصول إذا كان المستخدم الحالي لديه نفس UID مثل ownerUID في مستند سلة التسوق. بما أنّه لا حاجة إلى تحديد قواعد مختلفة لـ create, update, delete، يمكنك استخدام قاعدة write التي تنطبق على جميع الطلبات التي تعدّل البيانات.

عدِّل القاعدة للمستندات في المجموعة الفرعية للعناصر. يقرأ المتغيّر get في الشرط قيمة من Firestore، وهي في هذه الحالة ownerUID في مستند سلة التسوّق.

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. اختبار الوصول إلى السلع في سلة التسوّق

الآن يمكننا إعادة إجراء الاختبار. انتقِل إلى أعلى الناتج وتأكَّد من اجتياز المزيد من الاختبارات:

$ 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

رائع! الآن، تم اجتياز جميع الاختبارات. لدينا اختبار واحد في انتظار المراجعة، ولكننا سنصل إليه في بضع خطوات.

14. التحقّق من عملية "الإضافة إلى سلة التسوّق" مرة أخرى

ارجع إلى واجهة الويب الأمامية ( http://127.0.0.1:5000) وأضِف سلعة إلى سلة التسوّق. هذه خطوة مهمة للتأكد من أن اختباراتنا وقواعدنا تتطابق مع الوظائف التي يطلبها العميل. (تذكر أنه في المرة الأخيرة التي جربنا فيها واجهة المستخدم، لم يتمكن المستخدمون من إضافة عناصر إلى سلة التسوق الخاصة بهم!)

69ad26cee520bf24.png

يعيد العميل تحميل القواعد تلقائيًا عند حفظ firestore.rules. لذا، حاوِل إضافة منتج إلى سلة التسوّق.

ملخّص

أحسنت. لقد حسّنت للتو مستوى أمان تطبيقك، وهي خطوة أساسية لتجهيزه للإصدار العلني. إذا كان هذا تطبيقًا إنتاجيًا، فيمكننا إضافة هذه الاختبارات إلى مسار التكامل المستمر لدينا. سيمنحنا هذا الثقة في المستقبل بأنّ بيانات سلة التسوّق ستتضمّن عناصر التحكّم في الوصول هذه، حتى إذا كان الآخرون يعدّلون القواعد.

ba5440b193e75967.gif

ولكن انتظر، هناك المزيد!

إذا واصلت، ستتعلم:

  • كيفية كتابة دالة يتم تشغيلها بواسطة حدث Firestore
  • كيفية إنشاء اختبارات تعمل على محاكيات متعددة

15. إعداد اختبارات Cloud Functions

لقد ركّزنا حتى الآن على الواجهة الأمامية لتطبيق الويب وقواعد أمان Firestore. يستخدم هذا التطبيق أيضًا Cloud Functions لإبقاء سلة المستخدم محدّثة، لذا نريد اختبار هذا الرمز أيضًا.

تسهّل "مجموعة أدوات المحاكاة" اختبار Cloud Functions، حتى الدوال التي تستخدم Cloud Firestore وخدمات أخرى.

في المحرِّر، افتح ملف emulators-codelab/codelab-initial-state/functions/test.js وانتقِل إلى آخر اختبار في الملف. في الوقت الحالي، تم وضع علامة "في انتظار المراجعة" على:

//  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 () => {
    ...
  });
});

لتفعيل الاختبار، أزِل .skip ليظهر على النحو التالي:

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

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

بعد ذلك، ابحث عن المتغيّر REAL_FIREBASE_PROJECT_ID في أعلى الملف وغيِّره إلى رقم تعريف مشروع Firebase الحقيقي:

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

إذا نسيت رقم تعريف مشروعك، يمكنك العثور عليه في "إعدادات المشروع" (Project Settings) ضمن "وحدة تحكُّم Firebase"‏ (Firebase Console):

d6d0429b700d2b21.png

16- التعرّف على اختبارات Functions

بما أنّ هذا الاختبار يتحقّق من التفاعل بين Cloud Firestore وCloud Functions، فهو يتطلّب إعدادًا أكثر من الاختبارات في الدروس التطبيقية السابقة حول الترميز. لنتعرّف على هذا الاختبار ونأخذ فكرة عمّا يتوقعه.

إنشاء سلة تسوّق

تعمل Cloud Functions في بيئة خادم موثوقة ويمكنها استخدام مصادقة حساب الخدمة التي يستخدمها مدير SDK . أولاً، عليك إعداد تطبيق باستخدام initializeAdminApp بدلاً من initializeApp. بعد ذلك، يمكنك إنشاء DocumentReference لسلة التسوّق التي ستتم إضافة السلع إليها وإعداد السلة:

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 });

    ...
  });

تفعيل الدالة

بعد ذلك، أضِف مستندات إلى المجموعة الفرعية items من مستند سلة التسوّق لتفعيل الدالة. أضِف عنصرَين للتأكّد من أنّك تختبر الإضافة التي تحدث في الدالة.

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 });

    ...
    });
  });

تحديد التوقعات من الاختبار

استخدِم onSnapshot() لتسجيل مستمع لأي تغييرات في مستند سلة التسوّق. تعرض الدالة onSnapshot() دالة يمكنك استدعاؤها لإلغاء تسجيل أداة معالجة الحدث.

لهذا الاختبار، أضِف سلعتَين تبلغ تكلفتهما معًا 9.98 دولار أمريكي. بعد ذلك، تحقَّق مما إذا كانت سلة التسوّق تحتوي على itemCount وtotalPrice المتوقّعَين. إذا كان الأمر كذلك، فقد قامت الدالة بعملها.

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- إجراء الاختبارات

قد تظل المحاكيات قيد التشغيل من الاختبارات السابقة. وإذا لم تكن كذلك، ابدأ تشغيل المحاكيات. من سطر الأوامر، شغِّل

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

افتح علامة تبويب جديدة في Terminal (اترك المحاكيات قيد التشغيل) وانتقِل إلى دليل الدوال. قد تظل هذه الصفحة مفتوحة من اختبارات قواعد الأمان.

$ cd functions

الآن، شغِّل اختبارات الوحدة، ومن المفترض أن تظهر لك 5 اختبارات إجمالية:

$ 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

إذا نظرت إلى الخطأ المحدّد، يبدو أنّه خطأ في المهلة. هذا لأن الاختبار ينتظر تحديث الدالة بشكل صحيح، لكنه لا يحدث أبدًا. الآن، نحن مستعدون لكتابة الدالة لاجتياز الاختبار.

18- كتابة دالة

لإصلاح هذا الاختبار، عليك تعديل الدالة في functions/index.js. على الرغم من كتابة بعض هذه الدالة، إلا أنها غير مكتملة. هذا هو شكل الدالة حاليًا:

// 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) {
      }
    });

تضبط الدالة مرجع سلة التسوّق بشكل صحيح، ولكن بدلاً من حساب قيمتَي totalPrice وitemCount، يتم تعديلهما إلى قيم ثابتة.

جلب البيانات وتكرارها من خلال

items مجموعة فرعية

ابدأ ثابتًا جديدًا، itemsSnap، ليكون المجموعة الفرعية items. بعد ذلك، يمكنك تكرار العملية على جميع المستندات في المجموعة.

// 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) {
      }
    });

احتساب totalPrice وitemCount

أولاً، لنبدأ بضبط قيمتَي totalPrice وitemCount على صفر.

بعد ذلك، أضِف المنطق إلى قسم التكرار. أولاً، تأكَّد من أنّ السلعة لها سعر. إذا لم يتم تحديد كمية السلعة، اضبطها تلقائيًا على 1. بعد ذلك، أضِف الكمية إلى المجموع التراكمي لـ itemCount. أخيرًا، أضِف سعر السلعة مضروبًا في الكمية إلى المجموع التراكمي لـ 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) {
      }
    });

يمكنك أيضًا إضافة التسجيل للمساعدة في تصحيح أخطاء حالات النجاح والخطأ:

// 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. إعادة إجراء الاختبارات

في سطر الأوامر، تأكَّد من أنّ المحاكيات لا تزال قيد التشغيل وأعِد إجراء الاختبارات. لست بحاجة إلى إعادة تشغيل المحاكيات لأنّها تلتقط التغييرات في الدوال تلقائيًا. من المفترض أن تظهر لك جميع الاختبارات التي تم اجتيازها:

$ 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)

أحسنت!

‫20. تجربة الميزة باستخدام واجهة مستخدم واجهة المتجر

لإجراء الاختبار النهائي، ارجع إلى تطبيق الويب ( http://127.0.0.1:5000/‎) وأضِف سلعة إلى سلة التسوّق.

69ad26cee520bf24.png

تأكَّد من تعديل سلة التسوّق بالمبلغ الإجمالي الصحيح. رائع!

ملخّص

لقد اطّلعت على إطار اختبار معقّد بين "وظائف Firebase السحابية" وCloud Firestore. لقد كتبت دالة Cloud Function لاجتياز الاختبار. لقد تأكّدت أيضًا من أنّ الوظيفة الجديدة تعمل في واجهة المستخدم. لقد فعلت كل هذا محليًا، وقمت بتشغيل المحاكيات على جهازك الخاص.

لقد أنشأت أيضًا برنامج ويب يعمل مع المحاكيات المحلية، وقواعد أمان مخصّصة لحماية البيانات، واختبرت قواعد الأمان باستخدام المحاكيات المحلية.

c6a7aeb91fe97a64.gif