diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e89c07877eb..c639c37e8b7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,6 +25,7 @@ https://docs.communityhealthtoolkit.org/contribute/code/workflow/#commit-message # Code review checklist +- [ ] UI/UX backwards compatible: Test it works for the new design (enabled by default). And test it works in the old design, enable `can_view_old_navigation` permission to see the old design. - [ ] Readable: Concise, well named, follows the [style guide](https://docs.communityhealthtoolkit.org/contribute/code/style-guide/), documented if necessary. - [ ] Documented: Configuration and user documentation on [cht-docs](https://github.com/medic/cht-docs/) - [ ] Tested: Unit and/or e2e where appropriate diff --git a/.github/actions/deploy-conf/action.yml b/.github/actions/deploy-conf/action.yml index 4037f5cd455..557f4223135 100644 --- a/.github/actions/deploy-conf/action.yml +++ b/.github/actions/deploy-conf/action.yml @@ -28,6 +28,6 @@ runs: shell: bash working-directory: ${{ inputs.directory }} - name: run cht - run: npx cht --url=https://${{ inputs.username }}:${{ inputs.password }}@${{ inputs.hostname }} compile-app-settings convert-app-forms convert-collect-forms convert-contact-forms upload-app-settings upload-app-forms upload-collect-forms upload-contact-forms upload-resources upload-custom-translations --force + run: npx cht --url=https://${{ inputs.username }}:${{ inputs.password }}@${{ inputs.hostname }} compile-app-settings convert-app-forms convert-collect-forms convert-contact-forms convert-training-forms upload-app-settings upload-app-forms upload-collect-forms upload-contact-forms upload-training-forms upload-resources upload-custom-translations --force shell: bash working-directory: ${{ inputs.directory }} diff --git a/README.md b/README.md index 0291e7e92f8..1051f72d244 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Builds brought to you courtesy of GitHub Actions. ## Copyright -Copyright 2013-2022 Medic Mobile, Inc. +Copyright 2013-2025 Medic Mobile, Inc. ## License diff --git a/admin/src/js/services/session.js b/admin/src/js/services/session.js index 7b1dd7d79c6..68c6ab091bd 100644 --- a/admin/src/js/services/session.js +++ b/admin/src/js/services/session.js @@ -38,11 +38,14 @@ const _ = require('lodash/core'); } ipCookie.remove(COOKIE_NAME, { path: '/' }); userCtxCookieValue = undefined; + // Clear browser history to prevent loading page on browser's back button after logout. + $window.history.pushState(null, null, '/'); $window.location.href = `/${Location.dbName}/login?${params.toString()}`; }; const logout = function() { - return $http.delete('/_session') + return $http + .delete('/_session') .catch(function() { // Set cookie to force login before using app ipCookie('login', 'force', { path: '/' }); diff --git a/admin/tests/unit/services/session.spec.js b/admin/tests/unit/services/session.spec.js index dc20c145b94..3170e009857 100644 --- a/admin/tests/unit/services/session.spec.js +++ b/admin/tests/unit/services/session.spec.js @@ -6,6 +6,7 @@ describe('Session service', function() { let ipCookie; let ipCookieRemove; let location; + let pushStateStub; let $httpBackend; let Location; @@ -14,6 +15,7 @@ describe('Session service', function() { ipCookie = sinon.stub(); ipCookieRemove = sinon.stub(); ipCookie.remove = ipCookieRemove; + pushStateStub = sinon.stub(); Location = {}; location = {}; module(function ($provide) { @@ -24,6 +26,7 @@ describe('Session service', function() { $provide.factory('$window', function() { return { angular: { callbacks: [] }, + history: { pushState: pushStateStub }, location: location, }; }); @@ -59,6 +62,8 @@ describe('Session service', function() { $httpBackend.flush(); chai.expect(location.href).to.equal(`/DB_NAME/login?redirect=CURRENT_URL&username=${expected.name}`); chai.expect(ipCookieRemove.args[0][0]).to.equal('userCtx'); + chai.expect(pushStateStub.calledOnce).to.be.true; + chai.expect(pushStateStub.args[0]).to.have.members([ null, null, '/' ]); done(); }); @@ -73,6 +78,8 @@ describe('Session service', function() { $httpBackend.flush(); chai.expect(location.href).to.equal('/DB_NAME/login?redirect=CURRENT_URL'); chai.expect(ipCookieRemove.args[0][0]).to.equal('userCtx'); + chai.expect(pushStateStub.calledOnce).to.be.true; + chai.expect(pushStateStub.args[0]).to.have.members([ null, null, '/' ]); done(); }); @@ -96,6 +103,7 @@ describe('Session service', function() { service.checkCurrentSession(); $httpBackend.flush(); chai.expect(ipCookieRemove.callCount).to.equal(0); + chai.expect(pushStateStub.notCalled).to.be.true; done(); }); @@ -114,6 +122,8 @@ describe('Session service', function() { $httpBackend.flush(); chai.expect(location.href).to.equal(`/DB_NAME/login?redirect=CURRENT_URL&username=${expected.name}`); chai.expect(ipCookieRemove.args[0][0]).to.equal('userCtx'); + chai.expect(pushStateStub.calledOnce).to.be.true; + chai.expect(pushStateStub.args[0]).to.have.members([ null, null, '/' ]); done(); }); @@ -125,6 +135,7 @@ describe('Session service', function() { service.checkCurrentSession(); $httpBackend.flush(); chai.expect(ipCookieRemove.callCount).to.equal(0); + chai.expect(pushStateStub.notCalled).to.be.true; done(); }); diff --git a/api/resources/translations/messages-ar.properties b/api/resources/translations/messages-ar.properties new file mode 100644 index 00000000000..1b86b058362 --- /dev/null +++ b/api/resources/translations/messages-ar.properties @@ -0,0 +1,1352 @@ +Accept\ plain-text\ messages = قبول الرسائل النصية العادية +Accept\ plain-text\ messages\ help = حدّد مربع الاختيار إذا كنت تريد قبول الرسائل النصية SMS بالإضافة إلى التقارير. إذا لم يتم تحديد هذا المربّع، سيتم إرسال رسالة خطأ إلى أي شخص يرسل شيئاً غير التقارير. +Activity = النشاط +Add\ Message = إضافة رسالة +Add\ User = إضافة مستخدِم +Add\ Validation = إضافة تحقق +Add\ new\ language = إضافة لغة جديدة +Add\ person = إضافة شخص +Add\ place = إضافة مكان +Add\ recipient = إضافة مستلِم +Advanced = خيارات متقدّمة +Advanced\ settings\ intro = للتحقّق من وجود إصدارات أحدث من CHT وتثبيتها أو لضبط الإعدادات التقنية، يُرجى استخدام +Advanced\ settings\ outro = هذا مخصّص للمستخدمين ذوي خلفية تقنية عالية. +All\ contact\ types = كل أنواع جهات الاتصال +All\ facilities = كل الأماكن +All\ form\ types = كل أنواع النماذج +Analytics = التحليلات +Antenatal\ Care = الرعاية قبل الولادة +Any\ date = أي تاريخ +Any\ status = أي حالة +api.startup.checks = عمليات التحقق من التثبيت +api.startup.config = تطبيق التكوين +api.startup.forms = تثبيت النماذج +api.startup.install = جارٍ التثبيت +api.startup.index = جارٍ فهرسة البيانات +api.startup.migrate = جارٍ ترحيل البيانات +api.startup.title = جارٍ بدء {{ branding.name }} +Application\ Text = نص التطبيق +Apply = تطبيق +Appointment\ Date = تاريخ الموعد +At\ home\ with\ SBA = في المنزل بمساعدة قابلة ماهرة +At\ home\ without\ SBA = في المنزل بدون مساعدة قابلة ماهرة +Audit\ Logs = سجلات التدقيق +Automated\ Reply = رد تلقائي +Available\ Fields = الحقول المتاحة +Back = رجوع +Backup = نسخة احتياطية +Basic = أساسي +Bug\ description = وصف الخطأ +Cancel = إلغاء +Choose\ File = اختيار ملف +Choose\ file = اختيار ملف +Clear\ all\ filters = إعادة ضبط الفلاتر +Clinic = المنطقة +Clinic\ Contact\ Name = الاسم +Clinic\ Contact\ Phone = رقم الهاتف +Clinics = المناطق +Community\ health\ worker = عامل صحي مجتمعي +Configuration = تكوين +Confirm = تأكيد +Confirm\ Password = تأكيد كلمة المرور +Confirm\ delete = هل تريد بالتأكيد حذف {{name}}؟ لا يمكن التراجع عن هذه العملية. +Contact = جهة الاتصال +Contact\ type = نوع جهة الاتصال +Contacts = الأشخاص +Contacts\ file\ help = حدّد ملف json. الذي يحتوي على جهات الاتصال. +Content = المحتوى +Continue = متابعة +Current\ Password = كلمة المرور الحالية +Dashboard\ settings\ page = صفحة إعدادات لوحة المعلومات +Date\ display\ format = تنسيق عرض التاريخ +Datetime\ display\ format = تنسيق عرض التاريخ والوقت +Default = الإعدادات الافتراضية +Default\ Application\ Language = لغة التطبيق الافتراضية +Default\ country\ code = رمز البلد الافتراضي +Default\ country\ code\ help = سيتم تخصيص هذا الرمز لجميع أرقام الهواتف التي تم إدخالها في النظام بدون رمز البلد. +Delete = حذف +Deleting = جارٍ الحذف... +Disable = تعطيل +Discard\ changes\ to\ current\ language = هل تريد الاستمرار وتجاهل التغييرات التي أجريت على اللغة الحالية؟ +Display = عرض +District = المقاطعة +District\ Contact\ Name = اسم جهة الاتصال +District\ Contact\ Phone = رقم الهاتف +District\ Hospital = المقاطعة +District\ Name = اسم المقاطعة +Districts = المقاطعات +Download = تنزيل +EDD = تاريخ الولادة المتوقع +Edit = تعديل +Edit\ User = تعديل بيانات المستخدِم +Edit\ User\ Profile = تعديل ملف تعريف المستخدِم +Edit\ language = تعديل اللغة +Edit\ person = تعديل بيانات الشخص +Edit\ place = تعديل المكان +Edit\ translation = تعديل الترجمة +Email\ Address = عنوان البريد الإلكتروني +Enable = تمكين +Enter\ message = أدخل الرسالة +Error\ deleting\ document = حدث خطأ في حذف المستند +Error\ fetching\ contacts = حدث خطأ في إحضار الأشخاص +Error\ fetching\ messages = حدث خطأ في إحضار الرسائل +Error\ fetching\ reports = حدث خطأ في إحضار التقارير +Error\ fetching\ tasks = حدث خطأ في إحضار المهام +Error\ parsing\ file = حدث خطأ في تحليل الملف +Error\ parsing\ properties\ file = حدث خطأ في تحليل ملف الخصائص +Error\ retrieving\ settings = خطأ في استرداد الإعدادات +Error\ saving\ feedback = خطأ في حفظ الملاحظات +Error\ saving\ settings = حدث خطأ أثناء حفظ الإعدادات. يُرجى المحاولة مرة أخرى. +Error\ sending\ message = خطأ في إرسال الرسالة +Error\ updating\ contact = خطأ في التحديث +Error\ updating\ facility = خطأ في تحديث جهة الاتصال +Error\ updating\ group = خطأ في تحديث المجموعة +Error\ updating\ user = خطأ في تحديث بيانات المستخدِم +Errors = الأخطاء +Everyone\ at = {{facility}} - جميع جهة الاتصال وعددها {{count}} +Exactly = بالظبط +Export = تصدير +External\ ID = معرّف خارجي +Extra\ search\ words = كلمات بحث إضافية +Facility = المكان +Facility\ nurse = ممرضة المرفق +Failed\ validation = تعذر التحقّق +File\ not\ found = لم يتم العثور على الملف +For\ example = على سبيل المثال\: +Forms = نماذج التطبيق +From = مِن +Full\ Name = الاسم الكامل +Gateway\ number = رقم هاتف البوابة +Gateway\ number\ help = هذا هو الرقم الهاتفي الذي يجب على المراسلين المتنقلين إرسال تقاريرهم إليه، وهو أيضاً الرقم الذي سيتلقون منه الرسائل. +Generated\ report\ field = حقل التقرير المُنشأ +Health\ Center = مركز الصحة +Health\ Center\ Contact\ Name = اسم جهة الاتصال +Health\ Center\ Contact\ Phone = رقم الهاتف +Health\ Center\ Name = اسم مركز الصحة +Health\ Centers = مراكز الصحة +Help = المساعدة +Import = استيراد +Import\ translations = استيراد الترجمات +Incoming\ Reports = التقارير الواردة +Installed\ Forms = نماذج التطبيق المثبّتة +Institutional\ Delivery = التسليم المؤسسي +Invalid = غير صالح +Invalid\ contact\ numbers = هؤلاء المستلمون ليس لديهم رقم اتصال صالح/: {{recipients}} +LMP\ date = تاريخ الدورة الشهرية الأخيرة +Language = اللغة +Language\ For\ Outgoing\ Messages = لغة الرسائل الصادرة +Language\ code = رمز اللغة +Language\ code\ help = رمز اللغة المكوّن من رقمَين أو ثلاثة أرقام للغة التي تلي +Language\ name\ help = اسم العرض للغة. +Language\ to\ edit = اللغة التي يجب تعديلها +Languages = اللغات +Last\ Appointment = الموعد الأخير +Log\ Out = تسجيل الخروج +Message\ All = إرسال رسالة إلى الكل +Message\ Body = الرسالة +Message\ UUID = رسالة المعرّف الفريد العالمي +Messages = الرسائل +Messaging\ window = نافذة المراسلة +Missing\ translations = {{missing}}: ترجمات مفقودة +Mute = كتم الصوت +Name = الاسم +New\ person = شخص جديد +Next = التالي +No\ contact\ selected = لم يتم تحديد أي شخص +No\ contacts\ found = لم يتم العثور على أي أشخاص +No\ district = لا توجد مقاطعة +No\ forms\ found = لم يتم العثور على أي نماذج لهذا الجدول الزمني. +No\ message\ selected = لم يتم تحديد أي رسالة +No\ messages\ found = لم يتم العثور على أي رسائل. +No\ more\ contacts = لم يتم العثور على المزيد من الأشخاص +No\ more\ messages = لم يتم العثور على المزيد من الرسائل +No\ more\ reports = لم يتم العثور على المزيد من التقارير +No\ records\ found = لم يتم العثور على أي سجلات +No\ registrations\ found = لم يتم العثور على أي تسجيلات لهذا الجدول الزمني. يشير هذا إلى وجود مشكلة في التكوين. +No\ report\ selected = لم يتم تحديد أي تقرير +No\ reports\ found = لم يتم العثور على أي تقارير +No\ schedules\ found = لم يتم العثور على أي جداول زمنية +No\ submission = لا تشارك بيانات التأثير +No\ task\ selected = لم يتم تحديد أي مهمة +No\ tasks\ found = لم يتم العثور على أي مهام +Notes = ملاحظات +Notifications = الإشعارات +Number\ in\ month = {{count}} في {{month}} +Number\ of\ contact\ types = عدد أنواع جهات الاتصال {{number}} +Number\ of\ facilities = عدد الأماكن {{number}} +Number\ of\ form\ types = عدد أنواع النماذج {{number}} +Number\ of\ visits = زيارات تزيد عن {{number}}+ +On\ the\ day = في {{day}} +Outgoing\ Message = رسالة صادرة +Overview = نظرة عامة +Overwrite\ Existing\ Records = استبدال السجلات الموجودة +Password = كلمة المرور +Passwords\ must\ match = يجب أن تتطابق كلمات المرور. +Patient\ History = تاريخ المريضـ(ـة) +Patient\ ID = معرّف المريضـ(ـة) +Patient\ Name = اسم المريضـ(ـة) +Patient\ Report = تقرير المريضـ(ـة) +People = الأشخاص +Person = الشخص +Phone = الهاتف +Phone\ Number = الهاتف +Phone\ number\ conversion = تحويل رمز البلد +Phone\ number\ example = سيُضاف رمز البلد الافتراضي المكوّن مسبقاً إذا لزم الأمر، على سبيل المثال/: 0275551234 أو ‏‎+64275551234 +Phone\ number\ not\ valid = ليس رقم هاتف صالحاً. +Places = الأماكن +Please\ select\ a\ facility = يُرجى تحديد شخص لإعادة تعيينه كمرسِل لهذا التقرير. +Pregnant\ patient = المريضـ(ـة) الحامل +Previous = السابق +Primary\ Contact\ For = جهة اتصال رئيسية لـ +Primary\ contact = جهة اتصال رئيسية +Primary\ location = الموقع الأساسي +Processed\ number\ of\ total\ records = تمت معالجة {{number}} من السجلات وإجمالي عددها {{total}}... +RC\ Code = الرمز +Reading\ file = جارٍ قراءة الملف... +Registration\ example = على سبيل المثال، لتسجيل "{{name}}" سترسل\: +Registration\ format = قم بالتسجيل في سير عمل هذه الرسالة عن طريق إرسال رسالة SMS بالتنسيق التالي\: +Registrations = التسجيلات +Reload = إعادة تحميل +Replace\ country\ code\ for = استبدال رمز البلد لـ +Reply = رد +Reply\ to\ name = الرد على {{contact}} +Report\ Bug = الإبلاغ عن خطأ +Report\ format = تم تسجيله عن طريق إرسال رسالة SMS بالتنسيق التالي\: +Reporting\ Rates = معدلات إعداد التقارير +Reports = التقارير +Restore = استعادة +Save\ failed = تعذّر الحفظ +Saved = تم الحفظ +Schedule = الجدول الزمني +Schedule\ name = الجدول الزمني\: {{name}} +Schedules = الجداول الزمنية +Search = بحث +Select\ a\ language = اختيار لغة +Select\ a\ type = اختيار نوع +Send = إرسال +Send\ Message = إرسال رسالة +Send\ scheduled\ messages\ between = إرسال رسائل مُجدوَلة بين +Sending = جارٍ الإرسال... +Sent\ By = تم الإرسال مِن +Server\ Logs = سجلات الخادم +Set\ as\ default\ application\ language = ضبط كلغة تطبيق افتراضية +Set\ as\ language\ for\ outgoing\ messages = ضبط كلغة للرسائل الصادرة +Settings = الإعدادات +Showing\ number\ of\ total = إظهار {{number}} من {{total}}. +Skipped\ number\ of\ records = تم تخطي عدد من السجلات يبلغ {{number}}. +Start = ابدأ +Start\ messages\ based\ on = ابدأ الرسائل بناءً على +Start\ new\ conversation = ابدأ محادثة جديدة +Submit = إرسال +Submit\ Report = إرسال تقرير +Submit\ via\ either = جرّب أونلاين أولاً، ثم رسالة SMS +Submit\ via\ sms = رسائل SMS فقط +Submit\ via\ web = أونلاين فقط +Submitting = جارٍ الإرسال... +Task\ Message = رسالة المهمة +Tasks = المهام +The\ first\ time\ must\ be\ earlier\ than\ the\ second\ time = يجب أن تكون المرة الأولى قبل المرة الثانية. +The\ group\ must\ be\ an\ integer = يجب أن تكون المجموعة عدداً صحيحاً +The\ offset\ unit\ must\ be\ an\ integer = يجب أن تكون وحدة الإزاحة عدداً صحيحاً +The\ unit\ must\ be\ an\ integer = يجب أن تكون الوحدة عدداً صحيحاً +This\ message\ is\ part\ of\ group = هذه الرسالة جزء من المجموعة +To\ Phone = إلى الهاتف +Translation\ file = ملف الترجمة +Translation\ file\ help = حدِّد ملف properties. الذي تريد استيراده لاستبدال الترجمات الخاصة بهذه اللغة. أسهل طريقة لإنشاء ملف بالتنسيق الصحيح هي تصدير الترجمات لهذه اللغة، وإجراء أي تعديلات، ثم الاستيراد. +Translations = الترجمات +Unread\ below = غير مقروءة أدناه +Unsaved\ changes = تغييرات غير محفوظة +Unverified = لم يتم التحقق منه +Unverify = إلغاء التحقّق +Update = تحديث +Update\ Facility = تحرير بيانات المُرسِل +Upload\ Forms = تحميل نماذج التطبيق +Upload\ failed = تعذّر التحميل +Upload\ succeeded = نجح التحميل +User\ Feedback = ملاحظات المستخدِم +User\ Name = اسم المستخدِم +User\ Type = نوع المستخدِم +Users = المستخدمون +Valid = صالح +Validation\ message = إذا نجحت جميع عمليات التحقق، فسيتم إرسال هذه الرسالة إلى المُرسِل\: +Validations = عمليات التحقق +Validity = التحقّق +Verification = التحقق +Verified = تم التحقق منه +Verify = يتحقق +Village\ Name = المدينة +Visits = الزيارات +Weeks\ Pregnant = أسابيع الحمل +_id = تسجيل المعرّف الفريد العالمي +about = حول +about.cht = مشغّل بواسطة +action.clinic.add = إضافة منطقة +action.district_hospital.add = مقاطعة جديدة +action.health_center.add = مركز صحة جديد +action.person.add = شخص جديد +action.report.add = إجراء جديد +admin.app.name = إدارة التطبيق +admin.display = عرض +admin.display.datetime = التاريخ والوقت +admin.message.queue = الرسائل الصادرة +admin.message.queue.created = تم الإنشاء +admin.message.queue.due = الموعد النهائي +admin.message.queue.error = حدث خطأ في إحضار الرسائل +admin.message.queue.message = الرسالة +admin.message.queue.recipient = المستلِم +admin.message.queue.scheduled = مُجدوَل زمنياً +admin.message.queue.status = الحالة +admin.message.queue.tab.due = الموعد النهائي +admin.message.queue.tab.muted.future = لن يتم الإرسال +admin.message.queue.tab.muted.past = لم يتم الإرسال +admin.message.queue.tab.scheduled = مُجدوَل زمنياً +admin.message.queue.updated = آخر تحديث +admin.message.queue.view.report = عرض التقرير +admin.pagination.detail = إظهار {{first}} - {{last}} من إجمالي العناصر البالغ عددها {{total}} +admin.pagination.first = الأول +admin.pagination.last = الأخير +admin.pagination.next = التالي +admin.pagination.prev = السابق +admin.targets.description = سيُظهر هدف 1- مستهدفاً بدون هدف. +after\ the = بعد +analytics.anc.active-pregnancies = حالات الحمل النشطة +analytics.anc.delivery-locations = مواقع الولادة المبلغ عنها +analytics.anc.high-risk = حالات الحمل عالية الخطورة +analytics.anc.missed-appointments = المواعيد الفائتة مؤخراً +analytics.anc.missing-reports = تقارير الولادة الفائتة +analytics.anc.monthly-births = الولادات الشهرية +analytics.anc.monthly-registrations = حالات الحمل الشهرية المسجلة +analytics.anc.total-births = إجمالي عدد الولادات +analytics.anc.upcoming-appointments = المواعيد القادمة +analytics.anc.upcoming-edds = النساء اللاتي لديهن مواعيد مقبلة لتاريخ الولادة المتوقع +analytics.anc.visits-completed = الزيارات التي تم إكمالها أثناء الحمل +analytics.anc.visits-completed.description = حالات الحمل المكتملة التي كانت... +analytics.anc.visits-during = الزيارات المكتملة حتى الآن +analytics.anc.visits-during.description = حالات الحمل النشطة التي كانت... +analytics.connection.error = لم نتمكن من استرداد البيانات في هذا الوقت. +analytics.reporting.change_time_unit = تغيير وحدة الوقت +analytics.reporting.clinics_size = وحدة الإبلاغ +analytics.reporting.date_range = نطاق التاريخ +analytics.reporting.district = اختيار مقاطعة +analytics.reporting.form = اختيار نموذج +analytics.reporting.health_centers_size = مؤسسة صغير أو متوسطة الحجم +analytics.reporting.incomplete = تقارير غير صالحة +analytics.reporting.last_time_unit = آخِر {{quantity}} {{time_unit}} +analytics.reporting.not_submitted = التقارير مفقودة +analytics.reporting.reporting_rate = معدل الإبلاغ +analytics.reporting.reports = التقارير +analytics.target.add = إضافة مستهدف +analytics.target.aggregates = المجاميع المستهدفة +analytics.target.aggregates.disabled = المجاميع المستهدفة معطلة +analytics.target.aggregates.error = خطأ في إحضار المجاميع المستهدفة +analytics.target.aggregates.error.no.contact = خطأ في تحميل المجاميع المستهدفة. ليس لدى المستخدم مكان رئيسي، أو ليست لديه إمكانية الوصول إلى المكان الرئيسي المرتبط. تحدّث إلى المسؤول لديك لتصحيح هذا الأمر. +analytics.target.aggregates.error.not.found = خطأ في تحميل المجموع المستهدف\: لم يتم العثور على المستهدف. +analytics.target.aggregates.no.data = لا توجد بيانات +analytics.target.aggregates.no.target.selected = لم يتم تحديد المستهدف. +analytics.target.aggregates.no.targets = لم يتم العثور على المجاميع المستهدفة +analytics.target.aggregates.ratio = {{pass}} من {{total}} +analytics.target.aggregates.reported = تم الإبلاغ +analytics.target.aggregates.select.error = خطأ في تحميل المجموع المستهدف. +analytics.target.aggregates.supervisees.meeting.goal = هدف اجتماع العاملين الصحيين المجتمعيين +analytics.target.aggregates.total = الإجمالي +analytics.target.aggregates.reporting_period = فترة الإبلاغ +analytics.target.goal = الهدف +analytics.target.goal.help = إذا كنت لا تريد عرض هدف، فأدخِل قيمة "1-". +analytics.target.icon = أيقونة +analytics.target.icon.help = معرّف الأيقونة المُكوّنة. +analytics.target.id = معرّف فريد +analytics.target.monthly_goal = الهدف +analytics.target.name = الاسم +analytics.target.name.help = إذا أضفت ترجمات لاسم المستهدف هنا، فستظل بحاجة إلى إضافتها في صفحة اللغات أيضاً. +analytics.target.type = النوع +analytics.target.type.count = العدد +analytics.target.type.percent = النسبة المئوية +analytics.targets = المستهدفات +analytics.targets.registrations = العائلات المسجلة +analytics.targets.unique.id = يجب أن يكون معرّف المستهدف فريداً +analytics.unconfigured = لم يتم تكوين وحدات تحليلية. +and = و +and\ should\ be\ sent\ at = ويجب إرسالها في +android_app.data_usage.app.title = هذا التطبيق +android_app.data_usage.description = ملاحظة: هذه القيم موجودة منذ تشغيل الجهاز. +android_app.data_usage.rx = تم الاستلام +android_app.data_usage.system.title = على مستوى النظام +android_app.data_usage.title = استخدام بيانات التطبيق +android_app.data_usage.tx = تم النقل +android_app.os_android_version.title = نظام تشغيل أندرويد +android_app.package_name.title = اسم حزمة تطبيق أندرويد +android_app.version.title = إصدار تطبيق أندرويد +android_app.version_code.title = رمز نسخة تطبيق أندرويد +app.name = التطبيق +app.version.unknown = غير معروف - الاتصال بالإنترنت مطلوب. +associated.contact = جهة الاتصال المرتبطة +associated.contact.help = عندما ينشئ هذا المستخدم التقارير، سيتم تعيينها إلى جهة الاتصال هذه +autoreply = الرد التلقائي +birth_date = تاريخ الميلاد +branding = العلامة التجارية +branding.favicon.field = أيقونة صغيرة +branding.favicon.field.help = تظهر هذه الأيقونة في علامة تبويب المتصفح. يجب أن تكون بحجم 32 × 32 بكسل، ملف ico. +branding.logo.field = الشعار +branding.logo.field.help = يظهر في صفحة تسجيل الدخول والأجهزة ذات الشاشات الكبيرة. +branding.title.field = العنوان +branding.title.field.help = سيتم عرض هذا النص في علامة تبويب المتصفح وكعنوان لتطبيق الويب التقدمي. +branding.icon.field = أيقونة كبيرة +branding.icon.field.help = ستظهر لعمليات تثبيت تطبيق الويب التقدمي. يجب أن تكون بحجم 144 بكسل مربع على الأقل. +browser.compatibility.title = الاتصال بالمشرف +browser.compatibility.description = يُرجى الاتصال بالمشرف لديك وإخباره بأن متصفّحك يحتاج إلى تحديث. +browser.compatibility.confirm = موافق +bulkdelete.confirm.action = حذف +bulkdelete.confirm.title = هل تريد حذف السجل؟ +bulkdelete.confirm.title.plural = هل تريد حذف السجلات المحدّدة؟ +call = اتصال +case_id = معرّف الحالة +child_birth_date = تاريخ ميلاد الطفل +child_birth_outcome = حصيلة ميلاد الطفل +child_birth_weight = وزن الطفل عند الولادة +cleared = طابع زمني تم مسحه +clientDdoc.version = مراجعة تطبيق الويب الخاص بالعميلـ(ـة) +clinic.field.children = الأشخاص +clinic.field.contact = جهة اتصال رئيسية +clinic.field.external_id = معرّف خارجي +clinic.field.location = الموقع +clinic.field.name = الاسم +clinic.field.notes = ملاحظات +clinic.field.parent = مركز الصحة +close = إغلاق +configuration.authorization = الأدوار والأذونات +configuration.date.format.help = تنسيق Moment.js +configuration.enable.token.login = تمكين تسجيل الدخول عبر رابط رسائل SMS +configuration.enable.token.login.disable = تعطيل تسجيل الدخول عبر رسائل SMS. +configuration.enable.token.login.enabled.active = تم تمكين تسجيل الدخول عبر رسائل SMS لهذا المستخدم. تنتهي صلاحية الرابط في {{date}}. +configuration.enable.token.login.enabled.expired = تم تمكين تسجيل الدخول عبر رسائل SMS لهذا المستخدم. انتهت صلاحية الرابط في {{date}}. +configuration.enable.token.login.enabled.inactive = تم تمكين تسجيل الدخول عبر رسائل SMS لهذا المستخدم. تم الوصول إلى الرابط في {{date}}. +configuration.enable.token.login.help = عند الحفظ، سيتلقى المستخدِم تعليمات تسجيل الدخول ورابط تسجيل الدخول عبر رسائل SMS. يتطلب من المستخدِم أن يكون لديه رقم هاتف صالح. +configuration.enable.token.login.no.modify = لا تقم بإجراء أي تغييرات على تسجيل الدخول عبر رسائل SMS. +configuration.enable.token.login.phone = مطلوب رقم هاتف صالح لتسجيل الدخول عبر رسائل SMS. +configuration.enable.token.login.refresh = إعادة إنشاء وإعادة إرسال رسالة SMS لتسجيل الدخول. +configuration.enable.token.login.refresh.help = سيؤدي تعطيل أو إعادة إنشاء رسالة تسجيل الدخول عبر SMS إلى تغيير كلمة مرور المستخدِم، ما سيؤدي إلى تسجيل خروج المستخدِم. +configuration.messagetest = اختبار الرسالة +configuration.permission = الإذن +configuration.permissions = الأذونات +configuration.role = الدور +configuration.role.offline = غير متصل بالإنترنت +configuration.role.offline.warning = يستطيع المستخدمون الذين لديهم إمكانية الوصول بدون اتصال بالإنترنت عرض المستندات وتحديثها بدون الحاجة إلى اتصال بالإنترنت، ويمكنهم فقط عرض جزء من البيانات. يمكن لمستخدمي الإنترنت الوصول إلى جميع المستندات، ولكن يجب أن يكون لديهم اتصال بالإنترنت في جميع الأوقات. +configuration.roles = الأدوار +configuration.roles.add = إضافة دور جديد +configuration.sms = رسالة SMS +configuration.sms.forms = نماذج رسائل SMS +configuration.sms.forms.title = يجب اختيار ملف XML وملف بيانات التعريف قبل النقر على زر التحميل. لا يجوز تحميل أكثر من ملف نموذج تطبيق واحد في كل مرة، وسيتم استبدال أي نماذج موجودة. +configuration.sms.settings = الإعدادات الأساسية +configuration.sms.test.description = استخدم هذه الصفحة لإرسال رسالة اختبار إلى تطبيق الإنتاج بدون الحاجة إلى المرور عبر بوابة رسائل SMS. تأكد من استخدام رقم هاتف مسجل في ملف تعريف أحد العاملين الصحيين المجتمعيين لمحاكاة تقرير صادر عنه حول مريضـ(ـة) معيّنـ(ـة). +configuration.sms.test.from.number = من رقم الهاتف +configuration.sms.test.message.description = الحد الأقصى 144 حرفاً +configuration.sms.test.number.validation.description = يُرجى إدخال رقم هاتف صالح بدون شرطات أو علامات ترقيم. +configuration.sms.test.title = رسالة الاختبار +configuration.user.place.contact = يجب أن تكون جهة الاتصال المرتبطة تابعة للمكان. +configuration.user.replication.limit.exceeded = تحذير\! سيكرر هذا المستخدم {{total_docs}} من المستندات، ما يتجاوز الحد الموصى به. عدِّل "الدور" أو "المكان" لإجراء التغييرات اللازمة، ثم اضغط على "إرسال" للمتابعة. إذا كان هناك العديد من المستندات للمستخدم، يُنصح بتعديل قواعد حذف المستندات. +confirm.delete = حذف هذا السجل سيؤدي إلى إزالته نهائياً من تطبيقك. +confirm.delete.plural = سيتم حذف {{number}} سجلاً بشكل دائم. +confirm.delete.progress = الحذف جارٍ +confirm.delete.totals = تم حذف {{totalDeleted}}/{{totalSelected}}... +confirm.delete.user = هل تريد بالتأكيد حذف هذا المستخدم؟ لا يمكن التراجع عن هذه العملية. +confirm.destructive.navigation.forms = هذا النموذج غير مكتمل. ستفقد بياناتك إذا غادرت الآن. يكتمل النموذج فقط عندما تضغط على "إرسال". +confirm.destructive.navigation.submit = خروج +confirm.destructive.navigation.title = هل ترغب في الخروج من النموذج؟ +confirm.logout = ستحتاج إلى اتصال بالإنترنت لتسجيل الدخول مرة أخرى. +password.updated = تم تحديث كلمة مرورك بنجاح. +confirm.verification = سيتم التحقق من هذا التقرير كـ ?correct?. لا يمكن تغيير ذلك لاحقاً. +confirm.verification.submit = تحقق من الصحة +confirm.verification.title = تحقق من التقرير +contact.age = العمر +contact.created = تم إنشاء جهة الاتصال +contact.deceased.date.prefix = توفّي +contact.deceased.title = المتوفّون +contact.deceased.view = عرض عدد المتوفّين ({{count}}) +contact.history = سجلّ جهة الاتصال +contact.last.visited.date = تمت الزيارة في {{date}} +contact.last.visited.unknown = آخر زيارة غير معروفة +contact.muted = مكتوم +contact.muted.modal.text = جهة الاتصال هذه مكتومة حالياً. يمكنك المتابعة، ولكن ستظل جهة الاتصال مكتومة، ولن يتم إنشاء أي مهام لهذه الجهة حتى تقوم بإلغاء كتمها. +contact.muted.modal.title = جهة الاتصال المكتومة +contact.name = اسم جهة الاتصال +contact.new_place.button = مكان جديد +contact.no.children = لا يوجد أشخاص في هذا المكان. +contact.parent = ينتمي إلى +contact.parent.external_id = معرّف المنطقة الخارجية +contact.parent.name = اسم المنطقة +contact.parent.parent.contact.name = اسم جهة الاتصال بمركز الصحة +contact.parent.parent.external_id = المعرّف الخارجي لمركز الصحة +contact.parent.parent.name = اسم مركز الصحة +contact.parent.parent.parent.external_id = المعرّف الخارجي للمقاطعة +contact.parent.parent.parent.name = اسم المقاطعة +contact.place.id = معرّف المكان +contact.primary_contact_name = جهة اتصال رئيسية\: {{name || 'none'}} +contact.profile = ملف تعريف +contact.profile.anc_visit = زيارات الرعاية السابقة للولادة مكتملة +contact.profile.delivery_code.f = الولادة في المرفق +contact.profile.delivery_code.facility_birth = الولادة في المرفق +contact.profile.delivery_code.facility_stillbirth = ولادة وليد ميت في المرفق +contact.profile.delivery_code.home_no_sba_birth = الولادة في المنزل بدون مساعدة قابلة ماهرة +contact.profile.delivery_code.home_no_sba_stillbirth = ولادة وليد ميت بدون مساعدة قابلة ماهرة +contact.profile.delivery_code.home_sba_birth = الولادة في المنزل بمساعدة قابلة ماهرة +contact.profile.delivery_code.home_sba_stillbirth = ولادة وليد ميت في المنزل بمساعدة قابلة ماهرة +contact.profile.delivery_code.miscarriage = الإجهاض +contact.profile.delivery_code.ns = الولادة في المنزل بدون مساعدة قابلة ماهرة +contact.profile.delivery_code.s = الولادة في المنزل بمساعدة قابلة ماهرة +contact.profile.delivery_code.unknown = الولادة +contact.profile.delivery_date = تاريخ الولادة +contact.profile.edd = تاريخ الولادة المتوقع +contact.profile.exit_date = تاريخ الخروج +contact.profile.gender = الجنس +contact.profile.growth_monitoring = مراقبة النمو +contact.profile.height = الطول (سم) +contact.profile.height_at_exit = الطول عند الخروج (سم) +contact.profile.hfa = الدرجة المعيارية للطول بالنسبة للعمر +contact.profile.imam = الإدارة المتكاملة لسوء التغذية الحاد +contact.profile.imam_history = سجلّ الإدارة المتكاملة لسوء التغذية الحاد +contact.profile.imm.bcg = السل +contact.profile.imm.cholera = الكوليرا +contact.profile.imm.doses = {{count}} من {{total}} +contact.profile.imm.dpt = جرعة معززة من لقاح الدفتيريا والسعال الديكي والتيتانوس +contact.profile.imm.fipv = جرعة جزئية من لقاح شلل الأطفال المعطل +contact.profile.imm.flu = الإنفلونزا +contact.profile.imm.generic = عدد التقارير +contact.profile.imm.hep_a = التهاب الكبد A +contact.profile.imm.hep_b = التهاب الكبد B +contact.profile.imm.hpv = فيروس الورم الحليمي البشري +contact.profile.imm.ipv = شلل الأطفال غير النشط +contact.profile.imm.jap_enc = التهاب الدماغ الياباني +contact.profile.imm.meningococcal = المكورات السحائية +contact.profile.imm.mmr = الحصبة، النكاف، الحصبة الألمانية +contact.profile.imm.mmrv = الحصبة، النكاف، الحصبة الألمانية، الجدري المائي +contact.profile.imm.penta = اللقاح الخماسي التكافؤ +contact.profile.imm.pneumococcal = لقاح المكورات الرئوية +contact.profile.imm.polio = شلل الأطفال الفموي +contact.profile.imm.rotavirus = فيروس الروتا +contact.profile.imm.typhoid = التيفود +contact.profile.imm.vitamin_a = فيتامين A +contact.profile.imm.yellow_fever = الحمى الصفراء +contact.profile.immunizations = التطعيمات +contact.profile.last_treatment = آخر برنامج علاجي +contact.profile.muac = محيط العضد +contact.profile.nutrition_program = برنامج علاج التغذية +contact.profile.nutrition_program.otp = البرنامج العلاجي للمرضى الخارجيين +contact.profile.nutrition_program.sc = مركز إعادة التأهيل +contact.profile.nutrition_program.sfp = برنامج التغذية التكميلية +contact.profile.past_pregnancies = حالات الحمل السابقة +contact.profile.pnc_visit = زيارات رعاية ما بعد الولادة مكتملة +contact.profile.postnatal = رعاية ما بعد الولادة +contact.profile.pregnancy = الحمل +contact.profile.risk.high = عالي الخطورة +contact.profile.risk.normal = طبيعي +contact.profile.risk.title = الحالة +contact.profile.sessions = عدد الجلسات +contact.profile.visit = الزيارات المكتملة +contact.profile.visits.of = {{count}} من {{total}} +contact.profile.weight = الوزن (كغم) +contact.profile.weight_at_exit = الوزن عند الخروج (كغم) +contact.profile.wfa = الدرجة المعيارية للوزن بالنسبة للعمر +contact.profile.wfh = الدرجة المعيارية للوزن بالنسبة للطول +contact.select.error = خطأ في اختيار جهة الاتصال. +contact.sex = الجنس +contact.short = عامل صحي مجتمعي +contact.type.clinic = المنطقة +contact.type.clinic.edit = تعديل المنطقة +contact.type.clinic.new = منطقة جديدة +contact.type.clinic.plural = المناطق +contact.type.district_hospital = المقاطعة +contact.type.district_hospital.edit = تعديل المقاطعة +contact.type.district_hospital.new = مقاطعة جديدة +contact.type.district_hospital.plural = المقاطعات +contact.type.health_center = مركز الصحة +contact.type.health_center.edit = تعديل مركز الصحة +contact.type.health_center.new = مركز صحة جديد +contact.type.health_center.plural = مراكز الصحة +contact.type.person = الشخص +contact.type.person.edit = تعديل بيانات الشخص +contact.type.person.new = شخص جديد +contact.type.person.plural = الأشخاص +contact.type.place.edit = تعديل المكان +contact.type.place.new = مكان جديد +contact.type.wrong = النوع خطأ، جهة الاتصال ليست شخصاً. +contact.updated = تم تحديث جهة الاتصال +contacts.imported = تم استيراد جهات الاتصال بنجاح +contacts.results.sort = فرز النتائج +contacts.results.sort.alpha = أبجدياً +contacts.results.sort.date.last.visited = حسب تاريخ آخر زيارة +contacts.results.sort.title = ترتيب هذه القائمة +contacts.visits.count = {{count}} +contacts.visits.visits = {VISITS, plural, one{زيارة} other{زيارات}} +database_closed.description = حدث خطأ غير متوقع ويجب إعادة تحميل التطبيق. +database_closed.title = خطأ غير متوقع +date.from = مِن +date.incorrect.advice = يُرجى تصحيح إعدادات التاريخ والوقت في جهازك. +date.incorrect.confirm = موافق +date.incorrect.description = يبدو أن التاريخ/الوقت مضبوطان بشكل غير صحيح على هذا الجهاز. ضبط التاريخ أو الوقت بشكل غير صحيح يعني أن بياناتك ستُتلف، وقد يعني أنك قد ستفقد المستهدفات. +date.incorrect.expected = التاريخ المتوقع +date.incorrect.reported = التاريخ المُبلّغ عنه +date.incorrect.title = إعداد تاريخ خاطئ +date.time.title = التاريخ والوقت +date.to = إلى +date_filter.error.from_date = لا يمكن أن يكون تاريخ "من" بعد تاريخ "إلى". +date_filter.error.to_date = لا يمكن أن يكون تاريخ "إلى" قبل تاريخ "من". +days = الأيام +daysoverdue = الأيام منذ زيارة المريضـ(ـة) +ddoc.version = مراجعة تطبيق الويب للخادم +debug.db_info.docs = عدد المستندات +debug.db_info.name = الاسم +debug.db_info.seq = تحديث تسلسل +debug.db_info.title = معلومات قاعدة البيانات +debug.mode = تصحيح الأخطاء +debug.mode.description = وضع تصحيح الأخطاء سيعرض معلومات في وحدة تحكم المتصفح من أجل مساعدة المطوِّرين في تشخيص مشاكل التطبيق. بعد تغيير هذا الإعداد، أعِد تحميل التطبيق لتطبيق التغيير. +debug.mode.title = تمكين وضع تصحيح الأخطاء +debug.supported_browser = المتصفح المدعوم +debug.supported_browser.see_requirements = عرض المتطلبات +denied = الطابع الزمني المرفوض +display.language.accordion.title = ستكون اللغات الممكّنة متاحة للمستخدمين كخيار عند تحديد لغتهم الأساسية. ننصح باختيار لغة إلى 3 لغات مدعومة وتعطيل الباقي. من بين اللغات الممكّنة والمدعومة، يجب ألا تكون هناك ترجمات مفقودة. +display.privacy.policies.content.type = نوع المحتوى +display.privacy.policies.current = السياسة الحالية +display.privacy.policies.delete = حذف +display.privacy.policies.digest = الملخص +display.privacy.policies.failure = حدث خطأ أثناء معالجة طلبك +display.privacy.policies.name = اسم الملف المحلي +display.privacy.policies.no.changes = لم يتم الكشف عن أي تغييرات. +display.privacy.policies.preview = معاينة +display.privacy.policies.preview.error = خطأ في تحميل المعاينة +display.privacy.policies.preview.title = عرض سياسة الخصوصية للمعاينة للغة {{language}} +display.privacy.policies.preview.wrong.type = نوع المحتوى خاطئ. يُرجى استخدام نص/HTML. +display.privacy.policies.select = اختر ملف HTML +display.privacy.policies.size = الحجم +display.privacy.policies.submit.success = تم تحديث سياسات الخصوصية بنجاح +display.privacy.policies.title = سياسات الخصوصية +display.privacy.policies.update = تحديث السياسة +display.privacy.policies.upload = اضغط على "إرسال" لتحميل التغييرات. +display.translation.description = يُرجى ملاحظة أن أي تغييرات أو إضافات تجريها هنا على الترجمات لا يتم حفظها أو تعقبها في أي مكان آخر حالياً. +district_hospital.field.children = مراكز الصحة +district_hospital.field.contact = جهة اتصال رئيسية +district_hospital.field.external_id = معرّف خارجي +district_hospital.field.name = الاسم +district_hospital.field.notes = ملاحظات +document.deleted = تم حفظ السجل +document.deleted.plural = عدد السجلات المحذوفة {{number}} +edit.language.code.help.link = معيار ISO +edit.language.description = من الأفضل أن تُدمَج أي لغات جديدة تُضاف في صُلب CHT، بحيث تصبح متاحة للشركاء الآخرين. لا تتردد في التواصل إذا كانت لديك أي استفسارات حول إضافة لغة جديدة. +edit.name = تعديل الاسم +edit.user.settings = إعدادات المستخدم +edit_message_group.modal.title = تعديل الرسائل الصادرة +email.invalid = عنوان البريد الإلكتروني غير صالح. +empty = يبدو أنك أرسلت رسالة فارغة. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، يُرجى التواصل مع المشرف. +enketo.constraint.invalid = القيمة غير مسموح بها +enketo.constraint.required = هذا الحقل مطلوب +enketo.drawwidget.annotation = توضيح +enketo.drawwidget.drawing = رسم +enketo.drawwidget.signature = توقيع +enketo.error.max_attachment_size = الملفات المرفوعة تتجاوز الحد الإجمالي للحجم. يُرجى رفع ملفات أصغر. +enketo.form.required = مطلوب +enketo.filepicker.file = ملف +enketo.filepicker.placeholder = انقر هنا لتحميل الملف. (< {{maxSize}}) +enketo.filepicker.notFound = لم يتم العثور على الملف {{existing}} (اتركه بدون تغيير إذا تم تقديمه مسبقاً وتريد الاحتفاظ به). +enketo.filepicker.waitingForPermissions = في انتظار أذونات المستخدم. +enketo.filepicker.resetWarning = هذا الأمر سيؤدي إلى حذف {{item}}. هل تريد بالتأكيد القيام بذلك؟ +enketo.filepicker.toolargeerror = الملف كبير جداً (> {{maxSize}}) +enketo.geopicker.accuracy = الدقة (م) +enketo.geopicker.altitude = الارتفاع (م) +enketo.geopicker.closepolygon = أغلق المضلع +enketo.geopicker.kmlcoords = إحداثيات KML +enketo.geopicker.kmlpaste = الصق إحداثيات KML هنا +enketo.geopicker.latitude = خط العرض (س.ص \B0) +enketo.geopicker.longitude = خط الطول (س.ص \B0) +enketo.geopicker.points = النقاط +enketo.geopicker.searchPlaceholder = البحث عن مكان أو عنوان +enketo.geopicker.removePoint = سيؤدي ذلك إلى حذف النقطة الجغرافية الحالية بالكامل من القائمة ولا يمكن التراجع عن ذلك. هل تريد بالتأكيد القيام بذلك؟ +enketo.selectpicker.noneselected = لا شيء محدد +enketo.selectpicker.numberselected = تم تحديد {{number}} +error.403.description = ليس لديك صلاحيات كافية لعرض هذه الصفحة. تحدث مع المسؤول لزيادة صلاحياتك. +error.403.title = الوصول مرفوض +error.404.description = الصفحة التي كنت تبحث عنها غير موجودة. +error.404.title = غير موجودة +error.503.description = خطأ في التحميل. تحقق من اتصالك بالإنترنت وحاول مرة أخرى أو تحقق مع المسؤول. +error.503.title = خطأ +error.file.size = يجب ألا يزيد حجم الملف عن {{size}} +error.general.description = خطأ. يُرجى إعادة المحاولة. +error.general.title = خطأ +error.loading.form = خطأ في تحميل النموذج. يُرجى المحاولة مرة أخرى أو التحقق مع المسؤول. +error.loading.form.no_contact = خطأ في تحميل النموذج. المستخدم ليس لديه جهة اتصال مرتبطة أو ليس لديه حق الوصول إلى جهة الاتصال المرتبطة. تحدّث إلى المسؤول لديك لتصحيح هذا الأمر. +error.loading.form.no_authorized = خطأ في تحميل النموذج. المستخدم غير مصرح له بالوصول إلى هذا النموذج. تحدّث إلى المسؤول لديك لتصحيح هذا الأمر. +error.report.save = خطأ في حفظ التقرير +error.settings.loading = خطأ في تحميل الإعدادات. يُرجى المحاولة مرة أخرى. +expected_date = التاريخ المتوقع +export.button.download = تنزيل +export.dhis.dataset.description = اختر من تكاملات DHIS2 المتوفرة. +export.dhis.dataset.label = مجموعة بيانات DHIS2 +export.dhis.description = نزّل ملفاً يحتوي على بيانات DHIS2 DataValueSet للتكامل مع DHIS2. +export.dhis.period.description = اختر من الفواصل الزمنية الشهرية المتاحة. +export.dhis.period.label = فلترة حسب الفترة +export.dhis.place.all = كل الأماكن +export.dhis.place.description = فلتر البيانات المصدّرة لتشمل البيانات المرتبطة بجهات الاتصال ضِمن هذا المكان في التسلسل الهرمي. +export.dhis.place.label = فلترة حسب المكان +export.dhis.unconfigured = لم يتم تكوين تكامل DHIS2. +export.feedback.description = نزّل سجل الأخطاء المكتشفة وملاحظات المستخدمين المرسَلة عبر ميزة ?Report bug? بتنسيق CSV. يوضح الجدول أدناه أحدث التقارير المرسَلة. +export.messages.description = نزّل جميع الرسائل التي تم إرسالها أو استلامها على الإطلاق بتنسيق CSV. +export.people.description = نزّل جميع جهات الاتصال المسجلة في النظام بتنسيق JSON. +export.reports.description = نزّل ملخص لجميع التقارير التي تم إرسالها على الإطلاق بتنسيق CSV. +export.tabs.user_devices = أجهزة المستخدم +export.user-devices.description = نزّل ملخصاً لإصدارات البرامج المتعلقة بـ CHT (المتصفح، ومثيل CHT، ونظام التشغيل) التي تعمل على أجهزة المستخدمين. +يتم تصدير الملخص بتنسيق JSON. +extra_fields = حقول إضافية. +android_app_launcher.button.launch = تشغيل التطبيق +android_app_launcher.message.disable = لا تتوفر إمكانية تشغيل تطبيقات Android على هذا الجهاز. +failed\ validation\ response\ message = أرسل رسالة الاستجابة التالية إذا +fast_action_button.phone_call = اتصال +fast_action_button.send_message = إرسال رسالة +fast_action_button.title = جديد +fast_action_button.update_facility = تحديث المرفق +feedback.submitted = تم إرسال الملاحظات +feedback.modal.submit = التقرير +field\ digits\ only = {{field}} يجب أن يحتوي فقط على أرقام. +field\ does\ not\ pass\ this\ validation = الحقل لا يجتاز عملية التحقق هذه +field\ is\ required = {{field}} هو حقل مطلوب. +fields = الحقول +fields.one.required = أحد الحقول التالية مطلوب\: "{{fields}}" +fields.required = الحقول المطلوبة المفقودة\: "{{fields}}" +form = النموذج +form_invalid = لم يتم إكمال النموذج '{{form}}' بشكل صحيح. يرجى إكماله وإعادة إرساله. إذا استمرت هذه المشكلة، اتصل بالمشرف لديك. +form_invalid_custom = لم يتم إكمال النموذج '{{form}}' بشكل صحيح. يرجى إكماله وإعادة إرساله. إذا استمرت هذه المشكلة، اتصل بالمشرف لديك. +form_not_found = لم يتم التعرف على النموذج المرسَل. يُرجى إكماله وإعادة إرساله. إذا استمرت هذه المشكلة، اتصل بالمشرف لديك. +form_received = تم استلام إرسال النموذج. شكراً لك. +forms.json = JSON +forms.none.available = لا توجد نماذج متاحة حالياً. +forms.xml = XML +freetext.search = ابحث في كل شيء +from = مِن +generate.feedback.description = عدد مستندات الملاحظات المطلوب إنشاؤها\: +generate.feedback.title = إنشاء مستندات الملاحظات +health_center.field.children = المناطق +health_center.field.contact = جهة اتصال رئيسية +health_center.field.external_id = معرّف خارجي +health_center.field.name = الاسم +health_center.field.notes = ملاحظات +health_center.field.parent = المقاطعة +hours = ساعات +icon = أيقونة +icon.library = مكتبة أيقونات CHT +icons = الأيقونات +image = صورة +images = صور +images.header.tabs.icons = أيقونات علامات التبويب في الرأس +images.header.tabs.icons.default = الأيقونة الافتراضية +images.header.tabs.icons.description = قم بتكوين أيقونات علامة تبويب الرأس. يمكنك الاختيار بين أيقونات التطبيق بتنسيق svg. وأيقونات FontAwesome. +images.header.tabs.icons.fa.icon = أيقونة FontAwesome +images.header.tabs.icons.fontawesome.link = يُرجى قراءة المزيد عن FontAwesome. +images.header.tabs.icons.request.error = خطأ في إحضار الإعدادات. +images.header.tabs.icons.resource.icon = أيقونة المورد +images.header.tabs.icons.submit.failure = خطأ في إرسال الإعدادات +images.header.tabs.icons.submit.success = تم الإرسال بنجاح +images.header.tabs.icons.tab = علامة تبويب +images.icons.description = يتم استخدام الصور التي تم تحميلها هنا في جميع أنحاء التطبيق في أماكن مختلفة على النحو المحدد في رمز التطبيق. على سبيل المثال، يتم تحديد الصورة التي تظهر لأداة معينة في صفحة المستهدفات داخل رمز المستهدف. يمكنك تحميل الصور واحدة تلو الأخرى. وإذا حمّلت صورة تحمل نفس اسم صورة موجودة، فسيتم استبدالها بالملف الجديد. يُنصح أن تكون الصور بصيغة svg. أو png. وأن يكون حجمها أقل من 500 كيلوبايت. الموارد متاحة في +images.partners.description = أي شعارات لشريك تُضاف هنا ستظهر في صفحة "حول" في التطبيق. +import.export = استيراد وتصدير البيانات +initial.replication.duration = المدة +initial.replication.status = الحالة +initial.replication.status.complete = إكمال +initial.replication.status.failed = فشل +initial.replication.status.in_progress = قيد التنفيذ +initial.replication.status.pending = معلّق +initial.replication.title = التكرار الأولي +instance.stage.complete = تم الانتهاء من الإعداد. قم بالتثبيت عندما تكون جاهزاً. +instance.stage.confirm = تأكيد الإعداد +instance.stage.confirm.note = ملاحظة\: أنت تقوم بإعداد هذا النشر، ويعني هذا أنه سيتم تنفيذ أكبر قدر ممكن من العمل في الخلفية لتحضير عملية التثبيت بدون مقاطعة المستخدمين. لن يتم النشر حتى تُجري عملية النشر بعد اكتمال الإعداد. +instance.stage.deployment = جارٍ إعداد النشر +instance.upgrade = الترقيات +instance.upgrade.at = تشغيل +instance.upgrade.betas = النسخ التجريبية +instance.upgrade.branches = الفروع +instance.upgrade.build.version = الإصدار +instance.upgrade.cancel = إلغاء الترقية +instance.upgrade.cancelling = جارٍ إلغاء الترقية +instance.upgrade.cancel.note = سيؤدي الإلغاء إلى إيقاف الترقية الحالية وإعادة ضبط أي تقدم أُحرز أثناء الإعداد. +instance.upgrade.cancel.summary = أنت على وشك إلغاء الترقية من {{before}} إلى {{after}}. +instance.upgrade.complete = اكتمل النشر. +instance.upgrade.confirm = تأكيد الترقية +instance.upgrade.confirming = جارٍ تأكيد الترقية +instance.upgrade.confirm.summary = أنت على وشك الترقية من {{before}} إلى {{after}}. +instance.upgrade.confirm.warning.text = لا يمكن التراجع عن هذا الإجراء. تأكد من نسخ بياناتك احتياطياً\! +instance.upgrade.current_version = الإصدار الحالي +instance.upgrade.date = تاريخ الإصدار +instance.upgrade.deployed_by = تم النشر بواسطة +instance.upgrade.deployment = النشر قيد التقدم +instance.upgrade.error.abort = خطأ أثناء إلغاء الترقية +instance.upgrade.error.deploy = خطأ أثناء تشغيل التحديث +instance.upgrade.error.deploy_info_fetch = خطأ أثناء إحضار معلومات النشر +instance.upgrade.error.get_upgrade = خطأ أثناء إحضار تقدم الترقية +instance.upgrade.error.version_fetch = خطأ أثناء جلب الإصدارات المتاحة +instance.upgrade.feature_releases = الإصدارات التجريبية من الميزات +instance.upgrade.install = تثبيت +instance.upgrade.interrupted = تسبب خطأ غير متوقع في الخادم في انقطاع الترقية. +instance.upgrade.no_betas = لا توجد إصدارات تجريبية جديدة يمكنك الترقية إليها. +instance.upgrade.no_branches = لا توجد فروع إصدارات متاحة. +instance.upgrade.no_details = (التفاصيل غير متوفرة) +instance.upgrade.no_feature_releases = لا توجد إصدارات ميزات جديدة يمكنك الترقية إليها. +instance.upgrade.no_horti = Horticulturalist مطلوب +instance.upgrade.no_horti.detail = لاستخدام هذه الصفحة، يجب نشر المثيل الخاص بك عبر Horticulturalist. +instance.upgrade.no_new_releases = لا توجد إصدارات جديدة يمكنك الترقية إليها. +instance.upgrade.potentiallyIncompatible = قد يكون هذا الإصدار غير متوافق مع الإصدار الحالي لديك. +instance.upgrade.releases = الإصدارات +instance.upgrade.retry = إعادة محاولة الترقية +instance.upgrade.stage = الإعداد +instance.upgrade.state.completing = جارٍ إكمال الترقية +instance.upgrade.state.complete = الترقية مكتملة +instance.upgrade.state.finalizing = جارٍ إنهاء الترقية +instance.upgrade.state.initiated = تم بدء الترقية +instance.upgrade.state.staged = تم إعداد الترقية +instance.upgrade.state.indexing = جارٍ فهرسة طرق العرض المُعدّة +instance.upgrade.state.indexed = اكتملت الفهرسة +instance.upgrade.state.interrupted = منقطع +instance.upgrade.upgrading = تم بدء ترقية من {{before}} إلى {{after}}. +instance.upgrade.version = الإصدار +instance.upgrade.pre_releases = الإصدارات التجريبية +instance.upgrade.pre_releases_warning = الإصدارات في هذا القسم غير آمنة للنشر في بيئات الإنتاج. +invalid.query = هذا الاستعلام غير صالح. يُرجى الاطّلاع على صفحة مساعدة البحث المتقدم للحصول على مزيد من المعلومات حول بناء جملة الاستعلام. +login = تسجيل الدخول +login.error = حدث خطأ غير متوقع أثناء تسجيل الدخول. يُرجى إعادة المحاولة. +login.hide_password = إخفاء كلمة المرور +login.incorrect = اسم المستخدم أو كلمة المرور غير صحيحة. يُرجى إعادة المحاولة. +login.show_password = إظهار كلمة المرور +login.token.expired = انتهت صلاحية الرابط. اتصل بالمسؤول لتلقي رابط جديد. +login.token.general.error = حدث خطأ أثناء معالجة طلبك. يُرجى إعادة المحاولة. إذا حدث هذا مرة أخرى، فاتصل بالمسؤول لتلقي رابط جديد. +login.token.invalid = الرابط الخاص بك غير صالح. اتصل بالمسؤول لتلقي رابط جديد. +login.token.loading = جارٍ تسجيل دخولك. يرجى الانتظار. +login.token.missing = الرابط لا يحتوي على المعلومات المطلوبة. اتصل بالمسؤول لتلقي رابط جديد. +login.token.redirect.login = صفحة تسجيل الدخول +login.token.redirect.login.info = إذا كنت تعرف اسم المستخدم وكلمة المرور الخاصين بك، انقر على الرابط التالي لتحميل صفحة تسجيل الدخول. +login.token.timeout = انتهت مهلة المحاولة لتسجيل الدخول. يُرجى إعادة المحاولة. إذا حدث هذا مرة أخرى، فاتصل بالمسؤول لتلقي رابط جديد. +login.unsupported_browser = للحصول على تجربة أفضل في التطبيق، يُرجى الاتصال بالمسؤول أو المشرف لديك. +login.unsupported_browser.outdated_cht_android = اطلب منهم تحديث cht-android. +login.unsupported_browser.outdated_webview_apk = اطلب منهم تحديث ملف APK الخاص بـ WebView. +login.unsupported_browser.outdated_browser = اطلب منهم تحديث متصفحك. +message.characters.left = عدد الحروف المتبقي {{characters}} +message.characters.left.multiple = عدد رسائل SMS‏ {{messages}}، عدد الحروف المتبقية {{characters}} +message.characters.left.multiple.many = عدد رسائل SMS‏ {{messages}}، عدد الحروف المتبقية {{characters}} - عدد الرسائل كبير جداً\! +message.list.complete = لم يتم العثور على المزيد من الرسائل +messages.c.report_accepted = شكراً لك {{contact.name}} لتسجيل {{patient_name}}. المعرّف هو {{patient_id}}. تم تسجيلهم في الجدول الزمني لصحة الطفل. +messages.c.validation.months_since_birth = صيغة التسجيل لـ {{patient_id}} غير صحيحة. يُرجى التأكد من أن العمر مُحدد بالأشهر ويتراوح بين 0 و59. +messages.c.validation.patient_name = {{\#patient_name}}تنسيق التسجيل غير صحيح. ابدأ بحرف C متبوعاً بمسافة، والعمر بالأسابيع متبوعاً بمسافة، واسم الطفل (بحد أقصى 30 حرفاً).{{/patient_name}}{{^patient_name}}.لا يحتوي نموذج التسجيل على اسم. ابدأ بحرف C متبوعاً بمسافة، ثم العمر بالأسابيع متبوعاً بمسافة، ثم اسم الطفل{{/patient_name}}. +messages.c.validation.weeks_since_birth = صيغة التسجيل لـ {{patient_id}} غير صحيحة. يُرجى التأكد من أن العمر مُحدد بالأسابيع ويتراوح بين 0 و260. +messages.d.report_accepted = شكراً لك {{contact.name}} على الإبلاغ عن ولادة {{patient_name}} ({{patient_id}}). +messages.d.validation.days_since_birth = تقرير ولادة {{patient_id}} غير صحيح. يُرجى التأكد من أن عدد الأيام منذ الولادة هو عدد أيام يتراوح بين 0 و365، أو تركه فارغاً. +messages.d.validation.delivery_code = {{#delivery_code}} لم يتم فهم رمز الولادة. يُرجى استخدام الرمز 'F' للمرفق، و'S' للولادة في المنزل بمساعدة قابِلة ماهرة، و'NS' للولادة بدون مساعدة قابِلة ماهرة.{{/delivery_code}}{{^delivery_code}}كان رمز الولادة مفقوداً. يُرجى استخدام الرمز 'F' للمرفق، و'S' للولادة في المنزل بمساعدة قابِلة ماهرة، و'NS' للولادة بدون مساعدة قابِلة ماهرة.{{/delivery_code}} +messages.errors.invalid = رسالة غير صالحة\: +messages.errors.message.empty = الترجمة مفقودة +messages.errors.patient.missing = لم يتم العثور على المريضـ(ـة) +messages.errors.place.missing = لم يتم العثور على المكان +messages.errors.unknown.contact = جهة الاتصال هذه لم تعد موجودة، ولا يمكنك إرسال رسالة إليها. +messages.f.report_accepted = شكراً لك على الإبلاغ عن {{patient_name}} ({{patient_id}}). إذا كانت هذه حالة طارئة، يرجى إحالتها إلى العيادة فوراً. +messages.f.report_accepted_parent = تم الإبلاغ عن {{patient_name}} ({{patient_id}}) من قِبل {{contact.name}} ({{contact.phone}}) لمزيد من المتابعة. +messages.generic.no_provided_patient_id = تم تكوين دعم رقم المعرّف الخارجي، ولكن لا يمكن العثور عليه في المستند في الموقع الذي تم تكوينه. +messages.generic.provided_patient_id_not_unique = رقم المعرّف المقدم مستخدَم بالفعل. +messages.generic.provided_phone_not_valid = رقم الهاتف المقدّم غير صالح. +messages.generic.registration_accepted = شكراً لك {{contact.name}} لتسجيل {{patient_name}}. المعرّف هو {{patient_id}}. +messages.generic.registration_not_found = لم يتم العثور على شخص يحمل رقم المعرّف '{{patient_id}}'. تحقّق من المعرّف وأعد إرسال الرسالة. +messages.generic.report_accepted = شكراً لك {{contact.name}}، تم تسجيل {{form}} لـ {{patient_name}} ({{patient_id}}). +messages.generic.sys.facility_not_found = لم يتم تسجيلك لاستخدام هذا التطبيق. +messages.generic.validation.patient_id = {{\#patient_id}}رقم المعرّف المقدّم غير صحيح، ويجب أن يتكوّن من 5 إلى 13 رقماً. يُرجى إرسال معرّف صالح.{{/patient_id}}{{^patient_id}}تنسيق الرسالة غير صحيح. يرجى التأكد من إرسال رسالة تبدأ بـ {{form}} متبوعة بمسافة ثم معرّف الشخص.{{patient_id}} +messages.m.report_accepted = شكراً لك {{contact.name}}، تم تسجيل زيارة بعد الولادة لِـ {{patient_name}} ({{patient_id}}). +messages.n.report_accepted = شكراً لك {{contact.name}} لتسجيل {{patient_name}}. المعرّف هو {{patient_id}}. إذا كانت المرأة حاملاً، يرجى تسجيلها في الرسالة السابقة للولادة باستخدام النموذج P. +messages.n.validation.patient_name = {{\#patient_name}}تنسيق التسجيل غير صحيح. لذا تأكّد من أن الرسالة تبدأ بحرف N متبوعاً بمسافة واسم الشخص (بحد أقصى 30 حرفاً).{{/patient_name}}{{^patient_name}}تنسيق التسجيل غير صحيح. لذا تأكّد من أن الرسالة تبدأ بحرف N متبوعاً بمسافة واسم الشخص.{{/patient_name}}. +messages.off.report_accepted = لن يتم إرسال أي إشعارات أخرى بخصوص {{patient_name}} حتى تُرسل 'ON {{patient_id}}'.{{\#chw_sms}} {{chw_sms}}{{/chw_sms}} +messages.on.report_accepted = تمت إعادة تنشيط الإشعارات الخاصة بـ {{patient_name}} ({{patient_id}}).{{\#chw_sms}} {{chw_sms}}{{/chw_sms}} +messages.p.report_accepted = شكراً لك على تسجيل الحمل لِـ {{patient_name}} ({{patient_id}}).{{\#expected_date}} تاريخ الولادة المتوقع هو {{\#date}}{{expected_date}}{{/date}}{{/expected_date}} +messages.p.validation.last_menstrual_period = تنسيق التسجيل لـ {{patient_name}} غير صحيح. يرجى التأكد من أن الدورة الشهرية الأخيرة هو رقم بين 2 و42. +messages.relay.chw_sms = {{\#chw_sms}}{{chw_sms}}{{/chw_sms}} +messages.schedule.anc.checkin = أين وضعت {{patient_name}} مولودها؟ أجب باستخدام 'D {{patient_id}} F'‏ للولادة في المرفق، و'D {{patient_id}} S' للولادة بمساعدة قابِلة ماهرة، و'D {{patient_id}} NS' للولادة في المنزل بدون مساعدة قابِلة ماهرة. +messages.schedule.anc.followup = هل حضر(ت) {{patient_name}} زيارة الرعاية السابقة للولادة؟ عندما تفعل ذلك، يُرجى الرد بـ 'V {{patient_id}}'. شكراً لك\! +messages.schedule.anc.reminder = يرجى تذكير {{patient_name}} ({{patient_id}}) بزيارة مرفق الصحة لإتمام زيارة الرعاية السابقة للولادة هذا الأسبوع. عندما تقوم بهذه الخطوة، يُرجى إعلامنا بذلك باستخدام 'V {{patient_id}}'. شكراً\! +messages.schedule.anc.reminder_due = يجب على {{patient_name}} ({{patient_id}}) إجراء زيارة الرعاية السابقة للولادة هذا الأسبوع. يرجى التأكيد برسالة 'V'. إذا كانت قد وضعت مولودها، يُرجى إرسال رسالة "D". +messages.schedule.anc.week_38 = {{patient_name}} ({{patient_id}}) حامل في الأسبوع الـ 38 وستلد قريباً. يرجى إحالتها إلى مرفق الصحة. +messages.schedule.anc.week_40 = {{patient_name}} ({{patient_id}}) حامل في الأسبوع الـ 40 وستلد قريباً. يرجى إحالتها إلى مرفق الصحة. +messages.schedule.child.month_01 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_02 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_03 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_04 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_05 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_06 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_07 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_08 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_09 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_10 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_11 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_12 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_13 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_14 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_15 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_16 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_17 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_18 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_19 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_20 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_21 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_22 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_23 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_24 = يرجى زيارة {{patient_name}} {{patient_id}} للتذكير بالتطعيمات الواجبة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_25 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_26 = يُرجى زيارة {{patient_name}} {{patient_id}} للتأكد من الحصول على التطعيمات اللازمة + التحقق من علامات اعتلال الصحة. شكراً لك\! +messages.schedule.child.month_27 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_28 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_29 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_30 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_31 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_32 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_33 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_34 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_35 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_36 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_37 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_38 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_39 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_40 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_41 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_42 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_43 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_44 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_45 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_46 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_47 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_48 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_49 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_50 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_51 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_52 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_53 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_54 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_55 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_56 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_57 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_58 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_59 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.child.month_60 = يُرجى زيارة {{patient_name}} {{patient_id}} للتحقق من علامات اعتلال الصحة. راجع مرفق الصحة إذا لزم الأمر. شكراً لك\! +messages.schedule.imm.checkin = يُرجى زيارة {{patient_name}} ({{patient_id}}) لتذكيرها بالتطعيمات الواجبة . يرجى الإبلاغ عن أي لقاحات جديدة تم تلقيها. شكراً لك\! +messages.schedule.imm.followup = هل حضر(ت) {{patient_name}} ({{patient_id}}) الموعد لتلقي التطعيمات؟ عندما تفعل ذلك، يرجى الإبلاغ عن التطعيمات التي تم تلقيها. شكراً لك\! +messages.schedule.imm.reminder = يرجى تذكير {{patient_name}} ({{patient_id}}) بحضور موعد التطعيمات هذا الأسبوع. عندما تفعل ذلك، يرجى الإبلاغ عن التطعيمات التي تم تلقيها. شكراً لك\! +messages.schedule.postnatal.day_0 = يرجى متابعة {{patient_name}} ({{patient_id}}) للتأكد من حضورها زيارة ما بعد الولادة في اليوم 0 غداً. عندما تفعل ذلك، أخبرنا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.day_0_overdue = هل حضر(ت) {{patient_name}} ({{patient_id}}) زيارة ما بعد الولادة في اليوم 0؟ أخبرونا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.day_3 = يرجى متابعة {{patient_name}} ({{patient_id}}) للتأكد من حضورها زيارة ما بعد الولادة في اليوم 3 غداً. عندما تفعل ذلك، أخبرنا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.day_3_overdue = هل حضر(ت) {{patient_name}} ({{patient_id}}) زيارة ما بعد الولادة في اليوم 3؟ أخبرونا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.day_7 = يرجى متابعة {{patient_name}} ({{patient_id}}) للتأكد من حضورها زيارة ما بعد الولادة في اليوم 7 غداً. عندما تفعل ذلك، أخبرنا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.day_7_overdue = هل حضر(ت) {{patient_name}} ({{patient_id}}) زيارة ما بعد الولادة في اليوم 7؟ أخبرونا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.week_6 = يرجى متابعة {{patient_name}} ({{patient_id}}) للتأكد من حضورها زيارة ما بعد الولادة في الأسبوع 6 هذا الأسبوع. عندما تفعل ذلك، أخبرنا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.postnatal.week_6_overdue = هل حضر(ت) {{patient_name}} ({{patient_id}}) زيارة ما بعد الولادة في الأسبوع 6؟ أخبرونا بـ 'M {{patient_id}}'. شكراً\! +messages.schedule.registration.followup_anc = مرحباً {{contact.name}}، يُرجى تذكر تقديم تسجيل الحمل لِـ {{patient_name}} {{patient_id}} مع 'P {{patient_id}} <الأسابيع بعد الدورة الشهرية الأخيرة>'. شكراً\! +messages.schedule.registration.followup_anc_pnc = {{contact.name}}، هل احتاجت {{patient_name}} ({{patient_id}}) إلى الرعاية؟ لتسجيل الحمل، أرسل 'P {{patient_id}} <الأسابيع بعد الدورة الشهرية الأخيرة>'. لرعاية ما بعد الولادة، أرسل تقرير الولادة باستخدام الصيغة 'D {{patient_id}} '. شكراً لك\! +messages.sent.by = تم الإرسال من قِبَل {{senderName}} +messages.unknown.sender = المرسِل غير معروف +messages.v.report_accepted = شكراً لك {{contact.name}}، تمت تسجيل زيارة {{patient_name}} ({{patient_id}}). +metadata = بيانات التعريف +minutes = الدقائق +missing_fields = حقول مفقودة أو غير صالحة\: {{fields}}. +month = شهر +month.plural = أشهر +months = أشهر +mother_outcome = حصيلة صحة الأم +mrdt.disabled = تطبيق MRDT غير متوفر على هذا الجهاز. +mrdt.verify = التقاط صورة +muted = طابع زمني معطل +n.month = {MONTHS, plural, one{1 month} other{\# months}} +n.week = {WEEKS, plural, one{1 week} other{\# weeks}} +no = لا +number.bytes = {{number}} بايت +number.seconds = {{number}} ثانية +number\ errors = واجهنا {{number}} أخطا\: +number\ records = عدد السجلات {{number}} +online.action.message = هذا الإجراء يتطلب اتصالاً بالإنترنت. يرجى المحاولة مرة أخرى عند توفر الشبكة. +online.action.title = يرجى التأكد من الاتصال بالإنترنت. +overwrite.existing.records.help = إذا تم تحديد هذا الخيار، فسيتم تحديث جهات الاتصال التي تم تحميلها إذا كانت موجودة بالفعل في قاعدة البيانات. وإذا لم يتم تحديده، فسيتم تجاوزها. +partner.logo.field = شعار الشريك +partner.logo.upload = تحميل شعار الشريك +partner.name.field = اسم الشريك +partner.supporting = الشركاء الداعمون +partner.tab.partners = الشركاء +password.incorrect = كلمة المرور غير صحيحة. +password.length.minimum = يجب أن تكون كلمة المرور مؤلفة على الأقل من {{minimum}} حرفاً. +password.update = تحديث كلمة المرور +password.weak = كلمة المرور سهلة جداً ليتم تخمينها. يرجى تضمين مجموعة متنوعة من الحروف لجعلها أكثر تعقيداً. +patient\ id\ not\ found\ response = يُرجى إرسال الرسالة التالية إذا اجتازت جميع عمليات التحقق ولكن لم يتم العثور على معرّف المريضـ(ـة). +patient_id = معرّف المريضـ(ـة) +pending = الطابع الزمني معلّق +people = الأشخاص +permission = الإذن +permission.description.can_access_gateway_api = السماح بالوصول إلى واجهات برمجة التطبيقات للعمل كبوابة رسائل SMS. +permission.description.can_aggregate_targets = السماح بالاطّلاع على صفحة المجاميع المستهدفة +permission.description.can_bulk_delete_reports = السماح باستخدام وظائف الحذف المجمّع لحذف تقارير متعددة. +permission.description.can_configure = يسمح بتهيئة جميع تكوينات التطبيق. +permission.description.can_create_people = السماح بإنشاء أشخاص جدد. +permission.description.can_create_places = السماح بإنشاء أماكن جديدة. +permission.description.can_create_records = السماح بالوصول إلى واجهات برمجة التطبيقات لإنشاء التقارير. +permission.description.can_create_users = السماح بإنشاء مستخدمين. +permission.description.can_delete_contacts = السماح بحذف الأشخاص والأماكن. +permission.description.can_delete_messages = السماح بحذف الرسائل. +permission.description.can_delete_reports = السماح بحذف التقارير. +permission.description.can_delete_users = السماح بحذف المستخدمين. +permission.description.can_edit = السماح بإنشاء المستندات وتعديلها في قاعدة البيانات. +permission.description.can_edit_profile = السماح بتعديل إعدادات المستخدم الخاصة بهم. +permission.description.can_edit_verification = السماح بتحديث حالات التحقق من التقارير. +permission.description.can_export_all = السماح بتصدير جميع البيانات حتى لو لم يكن لديهم الإذن عادةً لعرضها. +permission.description.can_export_contacts = السماح بتصدير جميع جهات الاتصال. +permission.description.can_export_feedback = السماح بتصدير جميع ملاحظات المستخدمين. +permission.description.can_export_messages = السماح بتصدير جميع التقارير والرسائل. +permission.description.can_log_out_on_android = السماح بتسجيل الخروج من تطبيق أندرويد. +permission.description.can_update_places = السماح بتحديث مستندات الأماكن الحالية. +permission.description.can_update_reports = السماح بتحديث مستندات التقارير الحالية. +permission.description.can_update_users = السماح بتحديث المستخدمين الحاليين. +permission.description.can_verify_reports = السماح بتعيين حالات التحقق من التقرير إذا لم يتم تعيين أي حالة في الوقت الحالي. +permission.description.can_view_analytics = السماح بعرض التحليلات. +permission.description.can_view_analytics_tab = عرض علامة تبويب التحليلات في التطبيق. إذا لم يتم تعيينها سيتم عرض عنصر قائمة في قائمة التطبيق بدلاً من ذلك. +permission.description.can_view_call_action = عرض زر لبدء مكالمة مع الشخص المحدد. +permission.description.can_view_contacts = السماح بعرض جهات الاتصال. +permission.description.can_view_contacts_tab = عرض علامة تبويب جهات الاتصال في التطبيق. وإذا لم يتم تعيينها، فسيتم عرض عنصر قائمة في قائمة التطبيق بدلاً من ذلك. +permission.description.can_view_last_visited_date = عرض تاريخ آخر زيارة في قائمة جهات الاتصال. +permission.description.can_view_uhc_stats = السماح بعرض إحصائيات التغطية الصحية الشاملة في ملخص جهات الاتصال. +permission.description.can_view_message_action = عرض زر لإرسال رسالة إلى الشخص المحدد. +permission.description.can_view_messages = السماح بعرض الرسائل. +permission.description.can_view_messages_tab = عرض علامة تبويب الرسائل في التطبيق. وإذا لم يتم تعيينها، فسيتم عرض عنصر قائمة في قائمة التطبيق بدلاً من ذلك. +permission.description.can_view_old_navigation = السماح بعرض شريط التنقل القديم. +permission.description.can_view_outgoing_messages = السماح بعرض شاشة الرسائل الصادرة في تطبيق المسؤول. +permission.description.can_view_reports = السماح بعرض التقارير. +permission.description.can_view_reports_tab = عرض علامة تبويب التقارير في التطبيق. وإذا لم يتم تعيينها، فسيتم عرض عنصر قائمة في قائمة التطبيق بدلاً من ذلك. +permission.description.can_view_tasks = السماح بعرض المهام. +permission.description.can_view_tasks_group = السماح بعرض صفحة المهام للأسرة. +permission.description.can_view_tasks_tab = عرض علامة تبويب المهام في التطبيق. وإذا لم يتم تعيينها، فسيتم عرض عنصر قائمة في قائمة التطبيق بدلاً من ذلك. +permission.description.can_view_unallocated_data_records = السماح بالاطّلاع على التقارير التي لم يتم تعيين جهة اتصال لها. +permission.description.can_view_users = السماح بالحصول على قائمة بجميع المستخدمين المهيئين. +permission.description.can_write_wealth_quintiles = السماح بتحديث جهات الاتصال بخُمس ثرواتهم. +permission.description.can_upgrade = السماح بترقية إصدار CHT Core Framework عبر واجهة برمجة التطبيقات أو واجهة الإدارة. +permission.description.can_have_multiple_places.not_allowed = الأدوار المحددة ليس لديها إذن لتعيين أماكن متعددة. +permission.description.can_have_multiple_places.incompatible_place = يجب أن تكون الأماكن المحددة من نفس النوع (أن تكون في نفس مستوى التسلسل الهرمي). +permissions = الأذونات +person.field.alternate_phone = رقم هاتف بديل +person.field.code = الرمز +person.field.date_of_birth = تاريخ الميلاد +person.field.first_name = الاسم الأول +person.field.last_name = اسم العائلة +person.field.name = الاسم +person.field.national_id_number = رقم الهوية الوطنية +person.field.notes = ملاحظات +person.field.parent = المنطقة +person.field.phone = رقم الهاتف +person.field.title = العنوان +phone\ number\ not\ unique = هذا الرقم مسجل بالفعل لجهة الاتصال {{name}} +place.deleted = [محذوف] +place.unavailable = [غير متاح] +privacy.policy = سياسة الخصوصية +privacy.policy.accept = قبول +privacy.policy.not.found = سياسة الخصوصية غير موجودة +purge.description = قم بتشغيل فحص فوراً لإزالة المستندات المحلية المؤهلة. +purge.title = إزالة +quarter = الربع +quarter.plural = الأرباع +received = الطابع الزمني المُستلَم +registrant = المسجِّل +registrants\ supervisor = مشرف المسجِّل +registration\ date = تاريخ التسجيل +related_entities.clinic.contact.name = اسم جهة اتصال المنطقة +related_entities.clinic.external_id = معرّف المنطقة الخارجية +related_entities.clinic.name = اسم المنطقة +related_entities.clinic.parent.contact.name = اسم جهة الاتصال بمركز الصحة +related_entities.clinic.parent.external_id = المعرّف الخارجي لمركز الصحة +related_entities.clinic.parent.name = اسم مركز الصحة +related_entities.clinic.parent.parent.external_id = المعرّف الخارجي للمقاطعة +related_entities.clinic.parent.parent.name = اسم المقاطعة +related_entities.health_center.contact.name = اسم جهة الاتصال بمركز الصحة +related_entities.health_center.name = اسم مركز الصحة +related_entities.health_center.parent.name = اسم المقاطعة +reload.app = إعادة تحميل التطبيق +replication.disabled = معطّل للمسؤولين +replication.last.success.from = آخر نسخ متماثل ناجح من الخادم +replication.last.success.to = آخر نسخ متماثل ناجح إلى الخادم +replication.last.success.unknown = غير معروف +report.child_health_registration.birth_date = تاريخ الولادة +report.child_health_registration.chw_name = اسم العامل الصحي المجتمعي +report.child_health_registration.chw_phone = رقم هاتف العامل الصحي المجتمعي +report.child_health_registration.chw_sms = ملاحظة إلى العامل الصحي المجتمعي +report.child_health_registration.patient_id = المعرّف +report.child_health_registration.patient_name = الاسم +report.clinic_visit.title.sms = زيارة العيادة (رسالة SMS) +report.content.raw = محتوى التقرير الأولي +report.created = التقرير المقدم +report.delivery.birth_date = تاريخ الميلاد +report.delivery.chw_name = اسم العامل الصحي المجتمعي +report.delivery.chw_phone = رقم هاتف العامل الصحي المجتمعي +report.delivery.chw_sms = ملاحظة إلى العامل الصحي المجتمعي +report.delivery.delivery_code = رمز الولادة +report.delivery.delivery_code_label = موقع الولادة +report.delivery.delivery_date = تاريخ الولادة +report.delivery.label_delivery_code = موقع الولادة +report.delivery.label_pregnancy_outcome = حصيلة الحمل +report.delivery.patient_id = المعرّف +report.delivery.patient_name = الاسم +report.delivery.pregnancy_outcome = حصيلة الحمل +report.delivery.pregnancy_outcome_label = حصيلة الحمل +report.delivery.title.sms = تقرير الولادة (رسالة SMS) +report.edit = تعديل التقرير +report.flag.title.sms = إشارة علامة الخطر (رسالة SMS) +report.new_report.button = تقرير جديد +report.new_child.title.sms = تسجيل طفل جديد + تسجيل تطعيم (رسالة SMS) +report.new_person.title.sms = شخص جديد (رسالة SMS) +report.new_pregnancy.title.sms = حمل جديد (رسالة SMS) +report.off.title.sms = إيقاف تشغيل الإشعارات (رسالة SMS) +report.on.title.sms = تشغيل الإشعارات (رسالة SMS) +report.postnatal_visit.chw_name = اسم العامل الصحي المجتمعي +report.postnatal_visit.chw_phone = رقم هاتف العامل الصحي المجتمعي +report.postnatal_visit.chw_sms = ملاحظة إلى العامل الصحي المجتمعي +report.postnatal_visit.danger_signs_baby = علامات الخطر على الطفل الرضيع +report.postnatal_visit.danger_signs_mom = علامات خطر على الأم +report.postnatal_visit.patient_id = المعرّف +report.postnatal_visit.patient_name = الاسم +report.postnatal_visit.referral_follow_up_needed_baby = متابعة الإحالة المطلوبة للطفل الرضيع +report.postnatal_visit.referral_follow_up_needed_mom = متابعة الإحالة المطلوبة للأم +report.postnatal_visit.visit_confirmed = تم تأكيد الزيارة +report.pregnancy.chw_name = اسم العامل الصحي المجتمعي +report.pregnancy.chw_phone = رقم هاتف العامل الصحي المجتمعي +report.pregnancy.chw_sms = ملاحظة إلى العامل الصحي المجتمعي +report.pregnancy.danger_signs = علامات الخطر +report.pregnancy.edd = تاريخ الولادة المتوقع +report.pregnancy.lmp_date = تاريخ الدورة الشهرية الأخيرة +report.pregnancy.patient_id = المعرّف +report.pregnancy.patient_name = الاسم +report.pregnancy.risk_factors = عوامل الخطر +report.pregnancy_visit.chw_name = اسم العامل الصحي المجتمعي +report.pregnancy_visit.chw_phone = رقم هاتف العامل الصحي المجتمعي +report.pregnancy_visit.chw_sms = ملاحظة إلى العامل الصحي المجتمعي +report.pregnancy_visit.danger_signs = علامات الخطر +report.pregnancy_visit.patient_id = المعرّف +report.pregnancy_visit.patient_name = الاسم +report.pregnancy_visit.referral_follow_up_needed = متابعة الإحالة المطلوبة +report.pregnancy_visit.visit_confirmed = تم تأكيد الزيارة +reports.sidebar.filter.date = التاريخ +reports.sidebar.filter.from_date = مِن +reports.sidebar.filter.form_type = نوع النموذج +reports.sidebar.filter.place = المكان +reports.sidebar.filter.reset = إعادة ضبط +reports.sidebar.filter.status = الحالة +reports.sidebar.filter.submit = تطبيق +reports.sidebar.filter.title = الفلتر +reports.sidebar.filter.to_date = إلى: +report.subject.unknown = موضوع غير معروف +report.updated = تم تحديث التقرير +reported_date = التاريخ المُبلّغ عنه +reporter.link = انتقل إلى Medic Reporter +reporter.section.help = استخدم برنامج Medic Reporter لاختبار نماذج رسائل SMS. +reporter.section.title = Medic Reporter +reporting_unit_not_found = معرّف وحدة الإبلاغ غير صحيح. يرجى التصحيح وتقديم تقرير كامل مرة أخرى. +reports.none = لم يتم العثور على أي تقارير. +reports.none.n.months = {MONTHS, plural, one{No reports in the last month.} other{No reports in the last \# months.}} +reports.verify = المراجعة +reports.verify.invalid = يحتوي على أخطاء +reports.verify.valid = صحيح +responses = الاستجابات +save = حفظ التغييرات +saving = حفظ +schedule.anc_lmp = تذكيرات الرعاية السابقة للولادة الدورة الشهرية الأخيرة\: مجموعة {{group}} +schedule.anc_lmp_from_app = تذكيرات الرعاية السابقة للولادة الدورة الشهرية الأخيرة\: مجموعة {{group}} +schedule.anc_no_lmp = تذكيرات الرعاية السابقة للولادة\: مجموعة {{group}} +schedule.child_health = صحة الطفل\: مجموعة {{group}} +schedule.delivery_lmp = تذكيرات الولادة\: مجموعة {{group}} +schedule.delivery_lmp_from_app = تذكيرات الولادة\: مجموعة {{group}} +schedule.delivery_no_lmp = تذكيرات الولادة\: مجموعة {{group}} +schedule.imm = التطعيمات\: مجموعة {{group}} +schedule.nutrition_evaluation = تقييم التغذية +schedule.nutrition_otp = الجدول الزمني للبرنامج العلاجي للمرضى الخارجيين +schedule.nutrition_sc = الجدول الزمني لمركز إعادة التأهيل +schedule.nutrition_sc_exit = الجدول الزمني للخروج من مركز إعادة التأهيل +schedule.nutrition_sfp = الجدول الزمني لبرنامج التغذية التكميلية +schedule.pnc = رعاية ما بعد الولادة\: مجموعة {{group}} +schedule.pnc_facility_birth = رعاية ما بعد الولادة للولادة في المرفق\: مجموعة {{group}} +schedule.pnc_from_app = رعاية ما بعد الولادة من التطبيق\: مجموعة {{group}} +schedule.registration_anc = متابعة التسجيل +schedule.registration_anc_pnc = متابعة التسجيل +scheduled = الطابع الزمني المُجدوَل +scheduled_tasks = المهام المُجدوَلة +search_bar.filter.label = الفلتر +search_bar.sort.label = ترتيب +select.mode.count.plural = عدد السجلات المحددة {{number}} +select.mode.count.singular = سجل واحد تم تحديده +select.mode.delete.all = حذف الكل +select.mode.deselect.all = مسح الاختيار +select.mode.select.all = اختيار الكل +select.mode.start = اختيار +select.mode.stop = إغلاق +selection.doc.content.raw = محتوى التقرير الأولي +send\ the\ following\ message\ to\ the = أرسل الرسالة التالية إلى +sent = الطابع الزمني المُرسَل +sent_timestamp = الطابع الزمني المُرسَل +settings.backup.action = تنزيل الإعدادات الحالية +settings.backup.description = قم بتنزيل نسخة من الإعدادات الحالية. +settings.backup.title = رمز تطبيق النسخ الاحتياطي +settings.backuprestore = رمز تطبيق النسخ الاحتياطي +settings.backuprestore.description = يمكنك هنا تنزيل إعدادات التطبيق أو تحميلها بتنسيق JSON. يحتوي JSON هذا على معظم رمز التطبيق بخلاف النماذج والأيقونات والترجمات. +settings.restore.action = تحميل رمز التطبيق +settings.restore.description = استيراد ملف JSON جديد واستبدال الإعدادات الحالية. +settings.restore.title = تحميل رمز التطبيق +setup.language.outgoing.subtitle = اختر اللغة التي يرغب منسقو الرعاية لديك في تلقي الرسائل الآلية من CHT بها. +setup.language.subtitle = اختر اللغة الافتراضية التي سيستعملها مستخدموCHT. يمكن للمستخدمين ضبط التفضيلات الفردية بعد تسجيل الدخول. +sidebar_menu.title = القائمة +sms_message.message = رسالة واردة +sms_received = تم استلام رسالة SMS. ستتم مراجعتها قريباً. إذا كنت تحاول إرسال نموذج نصي، يرجى إدخال رمز النموذج الصحيح والمحاولة مرة أخرى. +state.cleared = تم المسح +state.delivered = تم التسليم +state.denied = مرفوض +state.duplicate = مكرر +state.failed = فشل +state.forwarded-by-gateway = أُعيد توجيهها عن طريق البوابة +state.forwarded-to-gateway = أُعيد توجيهها إلى البوابة +state.muted = تم الكتم +state.pending = معلّق +state.received = تم الاستلام +state.received-by-gateway = تم استلامها بواسطة البوابة +state.scheduled = تمت الجدولة +state.sent = تم الإرسال +status.review.correct = تمت المراجعة\: صحيح +status.review.errors = تمت المراجعة\: الأخطاء +status.review.title = مراجعة المدير +status.review.unverified = لم تتم المراجعة +status.sms.invalid = رسالة SMS غير صالحة +status.sms.title = صلاحية رسائل SMS +status.sms.valid = رسالة SMS صالحة +submit.icon = أيقونة التحميل +sync.last_success = آخر مزامنة +sync.now = إجراء المزامنة الآن +sync.retry = إعادة المحاولة +sync.feedback.failure.unknown = تعذّر إجراء المزامنة. تعذر الاتصال. +sync.status.in_progress = هل تجري المزامنة حالياً؟ +sync.status.not_required = تمت مزامنة جميع التقارير +sync.status.required = تقارير للمزامنة +sync.status.unknown = تعذر الاتصال +sys.empty = تظهر الرسالة فارغة. +sys.facility_not_found = المرفق غير موجود. +sys.form_not_found = النموذج '{{form}}' غير موجود. +sys.incorrect_type = نوع الحقل غير صحيح {{key}}، المتوقع {{expectedType}}. +sys.missing_fields = حقول مفقودة أو غير صالحة\: {{fields}}. +sys.recipient_not_found = تعذر العثور على مستلم الرسالة. +targets.all_time.subtitle = كل الوقت +targets.births.title = الولادات +targets.count.default = ({{pass}} من {{total}}) +targets.disabled = تم تعطيل المستهدفات لحساب المستخدم. +targets.no_targets = لم يتم العثور على مستهدف. +targets.this_month.subtitle = هذا الشهر +targets.last_month.subtitle = الشهر الماضي +task.date = تاريخ الاستحقاق +task.days.left = {DAYS, plural, one{1 day left} other{\# days left}} +task.list.complete = لا مزيد من المهام +task.overdue = موعد الاستحقاق اليوم +task.overdue.days = {DAYS, plural, =0{Due today} =1{Due yesterday} other{Due \# days ago}} +task.priority = الأولوية +task.state = حالة الرسالة +task.type = نوع الرسالة +tasks = الرسائل الصادرة +tasks.0.messages.0.message = الرسالة +tasks.0.messages.0.to = إلى +tasks.0.state = حالة +tasks.0.timestamp = الطابع الزمني +tasks.disabled = تم تعطيل المهام لحساب المستخدم. +tasks.none = لا توجد مهام. +tasks.none.n.weeks = {WEEKS, plural, one{No tasks in the next week.} other{No tasks in the next \# weeks.}} +tasks.group.leave = هل تريد بالتأكيد مغادرة هذه الصفحة؟ لن تتمكن بعد الآن من رؤية المهام الأخرى لهذه الأسرة. +tasks.group.completed = اكتمال جميع مهام الأسرة +tasks.group.title = مهام أخرى للأسرة +testing.description = لقد وجدت صفحة الاختبارات السرية الخاصة بنا التي تحتوي على الكثير من الوظائف الخطيرة. إذا لم تكن مستعداً لكسر التثبيت، فارجع الآن\! +testing.title = صفحة الاختبار +title = العنوان +to\ recipient = إلى {{recipient}} +today = اليوم +tomorrow = غداً +training_cards.confirm.exit = لم يتم الانتهاء من هذا التدريب. إذا غادرت الآن، ستفقد تقدمك وسيطلب منك إكماله في وقت لاحق. +training_cards.confirm.button.no = إلغاء +training_cards.confirm.button.yes = مغادرة +training_cards.confirm.title = هل تريد ترك التدريب؟ +training_cards.error.loading = خطأ في تحميل التدريب. يُرجى التواصل مع المشرف. +training_cards.error.save = خطأ في حفظ التدريب. +training_cards.form.saved = تم الانتهاء من التدريب. +training_materials.page.no_more_trainings = لا مزيد من التدريبات +training_materials.page.no_selected = لم يتم تحديد أي مواد تدريبية +training_materials.page.no_trainings = لم يتم العثور على أي تدريبات +training_materials.page.title = المواد التدريبية +translation.add = إضافة مفتاح ترجمة جديد +translation.key = مفتاح الترجمة +unique.id = معرّف فريد +unknown.contact = جهة اتصال غير معروفة +upgrade = الترقية +upgrade.description = لترقية تطبيقك إلى إصدار أو نسخة تجريبية أو فرع محدد، يوصى بإجراء الإعداد أولاً. وهذا يسمح بإنجاز العمل في الخلفية للتحضير لعملية التثبيت بدون مقاطعة المستخدمين. بمجرد اكتمال عملية الإعداد، انقر على "تثبيت" لمواصلة الترقية. لا يمكن التراجع عن هذا الإجراء، لذا يُرجى التأكد من عمل نسخة احتياطية لبياناتك وإخطار المستخدمين بفترة التوقف. +upload = التحميل +upload.favicon = رمز المفضلة +upload.header.logo = شعار الرأس +upload.icon = أيقونة التحميل +upload.json.forms.help = يجب أن يحتوي الملف على قائمة مشفرة بصيغة JSON لتعريفات النماذج. لا يجوز تحميل أكثر من ملف نموذج تطبيق واحد في كل مرة، وسيتم استبدال أي نماذج موجودة. +upload.json.forms.title = تحميل نماذج JSON +upload.sms.forms = تحميل نماذج رسائل SMS +upload.xform.context = التعريف +upload.xform.help = يجب أن تحتوي الملفات على نموذج XForm وسياق JSON المرتبط به. إذا كان هناك نموذج موجود، فسيتم استبداله. +upload.xform.meta.help = ملف بيانات التعريف بتنسيق JSON بما في ذلك الرمز والعنوان والسياق وما إلى ذلك. +upload.xform.title = تحميل نموذج XML +upload.xform.xml = XML +upload.xform.xml.help = تعريف xform في XML. +url = عنوان URL +user.fullname.help = الاسم الكامل للمستخدم +user.password.current = كلمة المرور الحالية +user.phone.help = يجب أن يكون رقم الهاتف صالحاً بدون شرطات أو علامات ترقيم. +user.place.help = يجب ربط جميع المستخدمين بمكان في النظام ليتم تحديد موقعهم بشكل صحيح في التسلسل الهرمي ورؤية البيانات المناسبة عند تسجيل الدخول. +user.username.help = هذا هو ما ستستخدمه لتسجيل الدخول إلى التطبيق. +username.invalid = اسم المستخدم غير صالح. الأحرف الصالحة هي الأحرف الصغيرة، والأرقام، والشرطة السفلية (_)، والواصلات (-). +username.taken = اسم المستخدم "{{username}}" محجوز بالفعل. +users.import.accepted_types = يتم قبول ملف بتنسيق csv. فقط. +users.import.cancel_upload = إلغاء +users.import.confirm_upload = نعم، قم بالتحميل +users.import.confirm_upload_text = هل تريد بالتأكيد تحميل +users.import.instructions = لإضافة عدة مستخدمين، يرجى تحميل جدول البيانات المناسب. +users.import.processing.title = معالجة المستخدمين +users.import.processing.instructions = قد تستغرق هذه العملية وقتاً طويلاً، إذا كنت تقوم بتحميل عدد كبير من المستخدمين، فيرجى التحلي بالصبر. +users.import.summary.added_users = تمت إضافة المستخدمين +users.import.summary.click = انقر +users.import.summary.download_status_file = لتحميل ملف الحالة الخاص بك. +users.import.summary.failed_users = تحتوي على أخطاء +users.import.summary.here = هنا +users.import.summary.previously_added_users = المستوردة سابقاً +users.import.summary.resubmit.instructions = لإعادة إرسال بيانات المستخدمين التي تحتوي على أخطاء: +users.import.summary.resubmit.step1 = 1. افتح ملف الحالة الذي تم تنزيله +users.import.summary.resubmit.step2 = 2. قارن الأخطاء مع جدول البيانات المنسق +users.import.summary.resubmit.step3 = 3. صحح الصفوف التي بها أخطاء وصدّرها إلى ملف بتنسيق csv. +users.import.summary.resubmit.step4 = 4. أعِد تحميل جدول البيانات المحدّث +users.import.title = الاستيراد من ملف +users.import.unavailable.instructions = يقوم شخص آخر في النظام حالياً بعملية إضافة عدة مستخدمين. +users.import.unavailable.retry = يرجى المحاولة مرة أخرى في وقت لاحق +users.import.unavailable.title = غير متوفر حالياً +users.import.upload_button = تحميل ملف csv. +users.manage.add_single_user = إضافة مستخدم +users.manage.back = العودة إلى قائمة المستخدمين +users.manage.import_users = الاستيراد من ملف +usertype._admin = المسؤول +usertype.admin = المسؤول +usertype.analytics = التحليلات - تصدير البيانات عبر عنوان URL فقط +usertype.computers = أجهزة الكمبيوتر +usertype.data-entry = إدخال البيانات - الوصول إلى Medic Reporter فقط +usertype.district-manager = المدير الإقليمي - وصول مقيّد +usertype.gateway = بوابة - مستخدم ذو وصول محدود لبوابة Medic +usertype.national-manager = المدير الوطني - الوصول إلى جميع المستندات +usertype.people = الأشخاص +usertype.unknown = غير معروف +validate.required = هذا الحقل مطلوب. +version = الإصدار +version.update.description = تم تحديث تطبيقك ويجب إعادة تحميله. إذا قمت بالإلغاء الآن، سيتم تذكيرك مرة أخرى بعد ساعتين. +version.update.title = التحديث متاح +view.all = عرض الكل +week = أسبوع +week.plural = أسابيع +weeks = أسابيع +wipe.device.description = تحذير\! سيؤدي ذلك إلى حذف جميع البيانات من جهازك وتسجيل خروجك. ستفقد أي بيانات غير متزامنة\! +wipe.device.title = مسح الجهاز +with = مع +with.lmp = مع الدورة الشهرية الأخيرة +without.lmp = بدون الدورة الشهرية الأخيرة +year = سنة +year.plural = سنوات +years = سنوات +yes = نعم +yesterday = أمس diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index cb04a0a6e72..3bc93546cdc 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -437,7 +437,7 @@ configuration.sms = SMS configuration.sms.forms = SMS forms configuration.sms.forms.title = You need to choose both an XML file and a Meta file before clicking the upload button. You may only upload one app form file at a time and any existing forms will be overwritten. configuration.sms.settings = Basic settings -configuration.sms.test.description = Use this page to send a test message to the production application without going through the SMS Gateway. Be sure to use a phone number registered to a CHW’s profile to mimic a report coming from him or her about a particular patient. +configuration.sms.test.description = Use this page to send a test message to the production application without going through the SMS Gateway. Be sure to use a phone number registered to a CHW?s profile to mimic a report coming from him or her about a particular patient. configuration.sms.test.from.number = From phone number configuration.sms.test.message.description = Limit of 144 characters configuration.sms.test.number.validation.description = Please enter a valid phone number without dashes or punctuation. @@ -454,7 +454,7 @@ confirm.destructive.navigation.submit = Exit confirm.destructive.navigation.title = Exit form? confirm.logout = You will need an internet connection to log back in. password.updated = Your password has been successfully updated. -confirm.verification = This report will be verified as “correct”. This cannot be changed later. +confirm.verification = This report will be verified as ?correct?. This cannot be changed later. confirm.verification.submit = Verify as correct confirm.verification.title = Verify report contact.age = Age @@ -654,8 +654,8 @@ enketo.geopicker.altitude = altitude (m) enketo.geopicker.closepolygon = close polygon enketo.geopicker.kmlcoords = KML coordinates enketo.geopicker.kmlpaste = paste KML coordinates here -enketo.geopicker.latitude = latitude (x.y °) -enketo.geopicker.longitude = longitude (x.y °) +enketo.geopicker.latitude = latitude (x.y ) +enketo.geopicker.longitude = longitude (x.y ) enketo.geopicker.points = points enketo.geopicker.searchPlaceholder = search for place or address enketo.geopicker.removePoint = This will completely remove the current geopoint from the list of geopoints and cannot be undone. Are you sure you want to do this? @@ -686,7 +686,7 @@ export.dhis.place.all = All Places export.dhis.place.description = Filter exported data to include data associated with contacts under this place in the hierarchy. export.dhis.place.label = Filter by place export.dhis.unconfigured = DHIS2 integration is not configured. -export.feedback.description = Download a log of detected errors and user feedback submitted via the “Report bug” feature in CSV format. The table below shows the most recently submitted reports. +export.feedback.description = Download a log of detected errors and user feedback submitted via the ?Report bug? feature in CSV format. The table below shows the most recently submitted reports. export.messages.description = Download all messages that have ever been sent or received in CSV format. export.people.description = Download all contacts registered in the system in JSON format. export.reports.description = Download a summary of all the reports that have ever been submitted in CSV format. @@ -1215,7 +1215,7 @@ sync.last_success = Last sync sync.now = Sync now sync.retry = Retry sync.feedback.failure.unknown = Sync failed. Unable to connect. -sync.status.in_progress = Currently syncing… +sync.status.in_progress = Currently syncing? sync.status.not_required = All reports synced sync.status.required = Reports to sync sync.status.unknown = Unable to connect @@ -1264,7 +1264,10 @@ training_cards.confirm.title = Leave training? training_cards.error.loading = Error loading training. Please contact your supervisor. training_cards.error.save = Error saving training. training_cards.form.saved = Training completed. -training_cards.modal.title = Important changes +training_materials.page.no_more_trainings = No more trainings +training_materials.page.no_selected = No training material selected +training_materials.page.no_trainings = No trainings found +training_materials.page.title = Training materials translation.add = Add new translation key translation.key = Translation key unique.id = Unique ID diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 2bb18b9bcf7..4dcc0aa8506 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = \¿Salir del entrenamiento\? training_cards.error.loading = Hubo un error al cargar el entrenamiento. Por favor contacte a su supervisor. training_cards.error.save = Hubo un error al guardar el entrenamiento. training_cards.form.saved = Entrenamiento completado. -training_cards.modal.title = Cambios importantes +training_materials.page.no_more_trainings = No hay más entrenamientos +training_materials.page.no_selected = Ningún material de entrenamiento seleccionado +training_materials.page.no_trainings = No se encontraron entrenamientos +training_materials.page.title = Materiales de entrenamiento translation.add = Agregar Traducción translation.key = Clave de traducción unique.id = Identificación única diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index a82b00af6de..0b9923ec74a 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -854,7 +854,7 @@ messages.n.report_accepted = Merci {{contact.name}} d'avoir enregistré {{patien messages.n.validation.patient_name = {{\#patient_name}} Le format est incorrect, assurez-vous que le message commence avec N suivi d'un espace et du nom de la personne (maximum 30 charactères).{{/patient_name}}{{^patient_name}} Le format est incorrect, assurez-vous que le message commence avec N suivi du nom de la personne.{{/patient_name}} messages.off.report_accepted = Aucune autre notification concernant {{patient_name}} ne sera envoyée tant que vous n'aurez pas envoyé 'ON {{patient_id}}'. {{\#chw_sms}} {{chw_sms}} {{/chw_sms}} messages.on.report_accepted = Les notifications pour {{patient_name}} ({{patient_id}}) ont été réactivées. {{\#chw_sms}} {{chw_sms}} {{/chw_sms}} -messages.p.report_accepted = Merci pour avoir enregistré la grossesse +messages.p.report_accepted = Merci pour avoir enregistré la grossesse de {{patient_name}} ({{patient_id}}).{{\#expected_date}} La date prévue pour l'accouchement est {{\#date}}{{expected_date}}{{/date}}{{/expected_date}} messages.p.validation.last_menstrual_period = Le format d'enregistrement pour {{patient_name}} est incorrect, merci de vous assurer que la dernière menstruation soit un nombre entre 2 et 42. messages.relay.chw_sms = {{\#chw_sms}}{{chw_sms}}{{/chw_sms}} messages.schedule.anc.checkin = Où est-ce que {{patient_name}} a acouché? Répondez par 'D {{patient_id}} F' pour un accouchement dans un centre de santé, 'D {{patient_id}} S' pour un accouchement à domicile avec une accoucheuse qualifiée, 'D {{patient_id}} NS pour un accouchement à domicile non qualifié. @@ -934,8 +934,8 @@ messages.schedule.postnatal.day_7 = Merci de vérifier si le {{patient_name}} ({ messages.schedule.postnatal.day_7_overdue = Est-ce que le patient {{patient_name}} ({{patient_id}}) est allé à la CPON du 7ème jour ? Confirmez avec 'M {{patient_id}}'. Merci \! messages.schedule.postnatal.week_6 = Merci de vérifier si le {{patient_name}} ({{patient_id}}) est partie cette semaine pour sa visite postnatale de la semaine - 6. Quand c'est fait, merci de nous le notifier avec 'M {{patient_id}}'. Merci \! messages.schedule.postnatal.week_6_overdue = Est-ce que {{patient_name}} ({{patient_id}}) est allée pour sa visite de 6eme semaine? Merci de confirmer en envoyant 'M {{patient_id}}'. Merci\! -messages.schedule.registration.followup_anc = Salut {{contact.name}}, n'oubliez de soumettre l'enregistrement de la grossesse pour {{patient_name}} {{patient_id}} avec 'P {{patient_id}} '. Merci\! -messages.schedule.registration.followup_anc_pnc = {{contact.name}}, est-ce que {{patient_name}} {{patient_id}} a besoin de soins? Pour enregistrer une grossesse envoyer 'P {{patient_id}} '. Pour les Soins postnataux, soumettre un rapport d'accouchement en envoyant 'D {{patient_id}} '. Merci\! +messages.schedule.registration.followup_anc = Salut {{contact.name}}, n'oubliez de soumettre l'enregistrement de la grossesse pour {{patient_name}} {{patient_id}} avec 'P {{patient_id}} '. Merci\! +messages.schedule.registration.followup_anc_pnc = {{contact.name}}, est-ce que {{patient_name}} {{patient_id}} a besoin de soins? Pour enregistrer une grossesse envoyer 'P {{patient_id}} '. Pour les Soins postnataux, soumettre un rapport d'accouchement en envoyant 'D {{patient_id}} '. Merci\! messages.sent.by = Soumis par {{senderName}} messages.unknown.sender = Émetteur inconnu messages.v.report_accepted = Merci beaucoup {{contact.name}}, la visite de {{patient_name}} ({{patient_id}}) a été enregistrée. @@ -1264,7 +1264,10 @@ training_cards.confirm.title = Quitter l'entraînement? training_cards.error.loading = Erreur lors du chargement de la formation. Veuillez contacter votre superviseur. training_cards.error.save = Erreur lors de l'enregistrement de la formation. training_cards.form.saved = Formation terminée. -training_cards.modal.title = Changements importants +training_materials.page.no_more_trainings = Aucune formation restante +training_materials.page.no_selected = Aucun matériel de formation sélectionné +training_materials.page.no_trainings = Aucune formation trouvée +training_materials.page.title = Matériel de formation translation.add = Ajouter une traduction translation.key = Clé de traduction unique.id = ID unique diff --git a/api/resources/translations/messages-hi.properties b/api/resources/translations/messages-hi.properties index 9b012194aff..e39d2bf0510 100644 --- a/api/resources/translations/messages-hi.properties +++ b/api/resources/translations/messages-hi.properties @@ -1168,6 +1168,10 @@ training_cards.confirm.exit = यह प्रशिक्षण समाप् training_cards.confirm.button.no = रद्द करें training_cards.confirm.button.yes = बाहर निकलें training_cards.confirm.title = प्रशिक्षण छोड़ें? +training_materials.page.no_more_trainings = अब और कोई प्रशिक्षण नहीं है +training_materials.page.no_selected = कोई प्रशिक्षण दस्तावेज़ नहीं चुना गया +training_materials.page.no_trainings = कोई प्रशिक्षण नहीं मिला +training_materials.page.title = प्रशिक्षण दस्तावेज़ translation.add = अनुवाद दर्ज करें translation.key = अनुवाद का गाइड unique.id = diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index 53a0b392f54..2e5074230a7 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -49,7 +49,7 @@ Contacts = Orang Contacts\ file\ help = Pilih file .json untuk kontak Content = Isi Continue = Lanjut -Current\ Password = +Current\ Password = Kata sandi saat ini Dashboard\ settings\ page = Halaman pengaturan dashboard Date\ display\ format = Format tampilan tanggal Datetime\ display\ format = Format tampilan tanggalwaktu @@ -61,7 +61,7 @@ Delete = Hapus Deleting = Menghapus Disable = Dimatikan Discard\ changes\ to\ current\ language = Anda ingin melanjutkan dan membuang perubahan yang dibuat bagi bahasa yang saat ini digunakan? -Display = +Display = Tampilan District = Kabupaten District\ Contact\ Name = Nama Kontak District\ Contact\ Phone = Nomor telepon @@ -162,7 +162,7 @@ No\ registrations\ found = Tidak ada registrasi yang ditemukan untuk jadwal ini. No\ report\ selected = Tidak ada laporan yang dipilih No\ reports\ found = Tidak ada laporan ditemukan No\ schedules\ found = Tidak ada jadwal yang ditemukan -No\ submission = +No\ submission = Jangan bagikan data dampak. No\ task\ selected = Tidak ada tugas yang dipilih No\ tasks\ found = Tidak ada tugas yang ditemukan Notes = Catatan @@ -280,18 +280,18 @@ action.district_hospital.add = Kabupaten Baru action.health_center.add = Puskesmas Baru action.person.add = Orang Baru action.report.add = Aksi Baru -admin.app.name = -admin.display = -admin.display.datetime = +admin.app.name = Manajemen Aplikasi +admin.display = Tampilan +admin.display.datetime = Tanggal & waktu admin.message.queue = Pesan Keluar -admin.message.queue.created = -admin.message.queue.due = +admin.message.queue.created = Dibuat +admin.message.queue.due = Jatuh tempo admin.message.queue.error = Error ketika mengambil pesan admin.message.queue.message = Pesan admin.message.queue.recipient = Penerima admin.message.queue.scheduled = Terjadwal admin.message.queue.status = Status -admin.message.queue.tab.due = +admin.message.queue.tab.due = Jatuh tempo admin.message.queue.tab.muted.future = Tidak akan dikirim admin.message.queue.tab.muted.past = Tidak terkirim admin.message.queue.tab.scheduled = Terjadwal @@ -302,7 +302,7 @@ admin.pagination.first = Pertama admin.pagination.last = Terakhir admin.pagination.next = Lanjut admin.pagination.prev = Sebelumnya -admin.targets.description = +admin.targets.description = Tujuan dengan nilai -1 akan menunjukkan target tanpa tujuan. after\ the = setelah analytics.anc.active-pregnancies = Kehamilan Aktif analytics.anc.delivery-locations = Lokasi persalinan yang dilaporkan @@ -331,28 +331,28 @@ analytics.reporting.not_submitted = Laporan hilang analytics.reporting.reporting_rate = Kecepatan Pelaporan analytics.reporting.reports = Laporan analytics.target.add = Tambah target -analytics.target.aggregates = -analytics.target.aggregates.disabled = -analytics.target.aggregates.error = -analytics.target.aggregates.error.no.contact = -analytics.target.aggregates.error.not.found = -analytics.target.aggregates.no.data = -analytics.target.aggregates.no.target.selected = -analytics.target.aggregates.no.targets = -analytics.target.aggregates.ratio = -analytics.target.aggregates.reported = -analytics.target.aggregates.select.error = -analytics.target.aggregates.supervisees.meeting.goal = -analytics.target.aggregates.total = +analytics.target.aggregates = Agregat target +analytics.target.aggregates.disabled = Agregat target dinonaktifkan +analytics.target.aggregates.error = Terjadi kesalahan saat mengambil agregat target +analytics.target.aggregates.error.no.contact = Terjadi kesalahan saat memuat agregat target. Pengguna Anda tidak memiliki tempat tinggal utama atau tidak memiliki akses ke tempat tinggal terkait. Hubungi administrator Anda untuk memperbaiki masalah ini. +analytics.target.aggregates.error.not.found = Terjadi kesalahan saat memuat agregat target\: Target tidak ditemukan. +analytics.target.aggregates.no.data = Tidak ada data +analytics.target.aggregates.no.target.selected = Tidak ada target yang dipilih. +analytics.target.aggregates.no.targets = Tidak ada agregat target yang ditemukan +analytics.target.aggregates.ratio = {{pass}} dari {{total}} +analytics.target.aggregates.reported = Dilaporkan +analytics.target.aggregates.select.error = Terjadi kesalahan saat memuat agregat target. +analytics.target.aggregates.supervisees.meeting.goal = CHW yang mencapai tujuan +analytics.target.aggregates.total = Total analytics.target.aggregates.reporting_period = Periode pelaporan analytics.target.goal = Tujuan -analytics.target.goal.help = +analytics.target.goal.help = Jika Anda tidak ingin menampilkan target, masukkan nilai '-1'. analytics.target.icon = Icon analytics.target.icon.help = ID Icon yang terkonfigurasi analytics.target.id = ID Unik analytics.target.monthly_goal = Tujuan Perbulan analytics.target.name = Nama -analytics.target.name.help = +analytics.target.name.help = Jika Anda menambahkan terjemahan untuk nama target di sini, Anda tetap harus menambahkannya di halaman bahasa. analytics.target.type = Tipe analytics.target.type.count = Hitung analytics.target.type.percent = Persen @@ -369,17 +369,19 @@ android_app.data_usage.system.title = Sistim-luas android_app.data_usage.title = Penggunaan Data App android_app.data_usage.tx = Dikirimkan android_app.version.title = Versi Android App -app.name = -app.version.unknown = +app.name = Aplikasi +app.version.unknown = Tidak Diketahui - koneksi internet diperlukan. associated.contact = Kontak yang berhubungan associated.contact.help = Ketika pengguna ini membuat laporan, mereka akan dihubungkan kepada kontak ini autoreply = jawab otomatis birth_date = Tanggal Lahir -branding = -branding.favicon.field = -branding.logo.field = -branding.title.field = +branding = Branding +branding.favicon.field = Ikon kecil +branding.logo.field = Logo +branding.title.field = Judul branding.title.field.help = +branding.icon.field = Ikon besar +branding.icon.field.help = Akan ditampilkan untuk instalasi PWA. Harus berukuran setidaknya 144 piksel persegi. browser.compatibility.title = Hubungi Supervisor Anda browser.compatibility.description = Silakan hubungi supervisor Anda untuk memperbarui browser Anda. browser.compatibility.confirm = Oke @@ -387,7 +389,7 @@ bulkdelete.confirm.action = Hapus bulkdelete.confirm.title = Hapus pencatatan? bulkdelete.confirm.title.plural = Hapus pencatatan yang dipilih? call = Telepon -case_id = +case_id = ID Kasus child_birth_date = Tanggal Lahir Anak child_birth_outcome = Outcome Anak dilahirkan child_birth_weight = Berat Lahir Anak @@ -948,9 +950,9 @@ person.field.title = Judul phone\ number\ not\ unique = Nomor ini sudah teregistrasi untuk kontak {{name}} place.deleted = [dihapus] place.unavailable = [tidak tersedia] -privacy.policy = -privacy.policy.accept = -privacy.policy.not.found = +privacy.policy = Kebijakan Privasi +privacy.policy.accept = Setujui +privacy.policy.not.found = Kebijakan Privasi tidak ditemukan purge.description = purge.title = quarter = Kuartal @@ -1106,15 +1108,12 @@ setup.start = Selesai setup.statistics.description = setup.statistics.title = sidebar_menu.title = Menu -simprints.disabled = -simprints.register = Daftar dengan Simprints -simprints.search = Cari dengan Simprints sms_message.message = Pesan Masuk sms_received = Pesan SMS telah diterima; akan segera dibaca. Bila anda ingin mengirim formulir teks, masukkan kode formulir yang benar dan kirim kembali. state.cleared = dibersihkan state.delivered = terkirim state.denied = ditolak -state.duplicate = +state.duplicate = duplikat state.failed = gagal state.forwarded-by-gateway = diteruskan oleh gateway state.forwarded-to-gateway = diteruskan ke gateway @@ -1124,16 +1123,16 @@ state.received = diterima state.received-by-gateway = diterima oleh gateway state.scheduled = terjadwal state.sent = terkirim -status.review.correct = -status.review.errors = -status.review.title = -status.review.unverified = +status.review.correct = Ditinjau\: benar +status.review.errors = Ditinjau\: terdapat kesalahan +status.review.title = Tinjauan manajer +status.review.unverified = Belum ditinjau status.sms.invalid = Invalid SMS status.sms.title = SMS validitas status.sms.valid = Valid SMS -submit.icon = +submit.icon = Unggah ikon sync.last_success = Sinkronisasi terakhir -sync.now = +sync.now = Sinkronkan sekarang sync.status.in_progress = Saat ini melakukan sinkronisasi sync.status.not_required = Semua laporan disinkronkan sync.status.required = Laporan yang perlu disinkronkan @@ -1141,12 +1140,12 @@ sync.status.unknown = Tidak diketahui sys.empty = Pesan terlihat kosong. sys.facility_not_found = Fasilitas tidak ditemukan. sys.form_not_found = Formulir '{{form}}' tidak ditemukan. -sys.incorrect_type = +sys.incorrect_type = Jenis bidang tidak sesuai {{key}}, diharapkan {{expectedType}}. sys.missing_fields = Tidak ditemukan atau salah\: {{fields}}. sys.recipient_not_found = Penerima pesan tidak ditemukan targets.all_time.subtitle = Total targets.births.title = Kelahiran -targets.count.default = {{pass}} dari {{total}} +targets.count.default = {{pass}} dari {{total}} targets.disabled = Target dinonaktifkan untuk pengguna admin. Jika Anda perlu melihat sasaran, login sebagai user biasa. targets.this_month.subtitle = Bulan ini targets.last_month.subtitle = Bulan sebelumnya @@ -1163,26 +1162,30 @@ tasks.0.messages.0.to = Kepada tasks.0.state = Status tasks.0.timestamp = Waktu tasks.disabled = Tugas dimatikan bagi pengguna admin. Bila ingin melihat tugas, login sebagai pengguna normal -tasks.none = Tidak ada tugas.\n -tasks.none.n.weeks = {WEEKS, plural, \=1{Tidak ada tugas di minggu depan.} other{Tidak ada tugas di \# minggu ke depan.}} +tasks.none = Tidak ada tugas. +tasks.none.n.weeks = {WEEKS, plural, one{Tidak ada tugas di minggu depan.} other{Tidak ada tugas di \# minggu ke depan.}} testing.description = -testing.title = +testing.title = title = -to\ recipient = Kepada {{recipient}} +to\ recipient = Kepada {{recipient}} today = hari ini tomorrow = besok training_cards.confirm.exit = Pelatihan ini belum selesai. Jika Anda keluar sekarang, Anda akan kehilangan kemajuan Anda dan akan diminta lagi nanti untuk menyelesaikannya. training_cards.confirm.button.no = Batalkan training_cards.confirm.button.yes = Keluar training_cards.confirm.title = Keluar dari pelatihan? +training_materials.page.no_more_trainings = Tidak ada lagi pelatihan +training_materials.page.no_selected = Tidak ada materi pelatihan yang dipilih +training_materials.page.no_trainings = Tidak ditemukan pelatihan +training_materials.page.title = Materi pelatihan translation.add = Tambah terjemahan translation.key = Kunci terjemahan -unique.id = -upgrade = -upgrade.description = -upload = -upload.favicon = -upload.header.logo = +unique.id = ID Unik +upgrade = Tingkatkan +upgrade.description = Untuk meningkatkan aplikasi Anda ke rilis, beta, atau cabang tertentu, disarankan untuk melakukan staging terlebih dahulu. Ini memungkinkan pekerjaan latar belakang diselesaikan untuk mempersiapkan instalasi tanpa mengganggu pengguna. Setelah proses staging selesai, klik Instal untuk melanjutkan peningkatan. Tindakan ini tidak dapat dibatalkan, jadi pastikan data Anda telah dicadangkan dan pengguna Anda telah diberi tahu tentang waktu henti. +upload = Unggah +upload.favicon = Favicon +upload.header.logo = Logo Header upload.icon = Unggah Icon upload.json.forms.help = File harus berisi daftar kode JSON tentang definisi formulir. Semua formulir yang sudah ada akan digantikan. upload.json.forms.title = Unggah formulir JSON @@ -1224,8 +1227,8 @@ welcome.setup.description = Gunakan petunjuk kami untuk pengaturan penting pada welcome.setup.title = Ijinkan kami mengatur sistim anda welcome.tour.description = Gunakan tur produk untuk mengenal platform Medic. Tur akan otomatis mulai setelah konfigurasi selesai. Anda dapat menemukannya pada menu drop-down. welcome.tour.title = Ijinkan kami untuk menjelaskan -wipe.device.description = -wipe.device.title = +wipe.device.description = PERINGATAN! Ini akan menghapus semua data dari perangkat Anda dan mengeluarkan Anda. Data yang belum tersinkronisasi akan hilang! +wipe.device.title = Hapus Data Perangkat with = Dengan with.lmp = Dengan Periode Menstruasi Terakhir without.lmp = Tanpa Periode Menstruasi Terakhir diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index ab858cbc35b..78fa02deecf 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = तालिम छोड्ने हो? training_cards.error.loading = तालिम लोड गर्दा त्रुटि भयो। तपाइँको सुपरभाइजरलाई सम्पर्क गर्नुहोस्। training_cards.error.save = तालिम सेभ गर्न त्रुटि। training_cards.form.saved = तालिम सम्पन्न भयो। -training_cards.modal.title = महत्वपुर्ण परिवर्तनहरु +training_materials.page.no_more_trainings = थप तालिम छैन +training_materials.page.no_selected = कुनै तालिम सामग्री छनोट गरिएको छैन। +training_materials.page.no_trainings = तालिम फेला परेन +training_materials.page.title = तालिम शीर्षक translation.add = नयाँ अनुवाद कुञ्जी थप्नुहोस् translation.key = अनुवाद कुञ्जी unique.id = आईडी diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index b7e0f9dae7e..13c4eadb09e 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = Ungependa kuondoka kwenye mafunzo? training_cards.error.loading = Hitilafu katika kupakia mafunzo. Tafadhali wasiliana na msimamizi wako. training_cards.error.save = Hitilafu katika kuhifadhi mafunzo. training_cards.form.saved = Mafunzo yamekamilika. -training_cards.modal.title = Mabadiliko muhimu +training_materials.page.no_more_trainings = Hakuna mafunzo mengine +training_materials.page.no_selected = Hakuna nyenzo za mafunzo zilizochaguliwa +training_materials.page.no_trainings = Hakuna mafunzo yaliyopatikana +training_materials.page.title = Vifaa vya mafunzo translation.add = Ongeza tafsiri translation.key = Ufunguo wa tafsiri unique.id = Kitambulisho cha kipekee diff --git a/api/server.js b/api/server.js index fb8d799d8ef..6364630fda2 100644 --- a/api/server.js +++ b/api/server.js @@ -82,7 +82,6 @@ process await migrations.run(); logger.info('Database migrations completed successfully'); - startupLog.start('forms'); logger.info('Generating manifest'); await manifest.generate(); logger.info('Manifest generated successfully'); @@ -90,15 +89,20 @@ process logger.info('Generating service worker'); await generateServiceWorker.run(true); logger.info('Service worker generated successfully'); + } catch (err) { + logger.error('Fatal error initialising API'); + logger.error('%o', err); + process.exit(1); + } + try { + startupLog.start('forms'); logger.info('Updating xforms…'); await generateXform.updateAll(); logger.info('xforms updated successfully'); - } catch (err) { - logger.error('Fatal error initialising API'); + logger.error('Error initialising API'); logger.error('%o', err); - process.exit(1); } startupLog.complete(); diff --git a/api/src/errors.js b/api/src/errors.js new file mode 100644 index 00000000000..70a3de8f368 --- /dev/null +++ b/api/src/errors.js @@ -0,0 +1,19 @@ +class PublicError extends Error { + constructor(publicMessage, ...args) { + super(publicMessage, ...args); + this.publicMessage = publicMessage; + } +} + +class NotFoundError extends Error { + constructor(message, ...args) { + super(message, ...args); + this.status = 404; // simulate PouchDb error + this.statusCode = 404; // simulate Request error + } +} + +module.exports = { + PublicError, + NotFoundError, +}; diff --git a/api/src/public-error.js b/api/src/public-error.js deleted file mode 100644 index 3d207200c7b..00000000000 --- a/api/src/public-error.js +++ /dev/null @@ -1,8 +0,0 @@ -class PublicError extends Error { - constructor(publicMessage, ...args) { - super(publicMessage, ...args); - this.publicMessage = publicMessage; - } -} - -module.exports = PublicError; diff --git a/api/src/services/privacy-policy.js b/api/src/services/privacy-policy.js index ec1d6dd5e3c..4555195757d 100644 --- a/api/src/services/privacy-policy.js +++ b/api/src/services/privacy-policy.js @@ -2,6 +2,7 @@ const sanitizeHtml = require('sanitize-html'); const db = require('../db'); const logger = require('@medic/logger'); const config = require('../config'); +const { NotFoundError } = require('../errors'); const PRIVACY_POLICY_DOC_ID = 'privacy-policies'; @@ -22,7 +23,7 @@ const getDoc = (options=({})) => { .then(doc => { const policies = doc.privacy_policies; if (!policies || !Object.keys(policies).length) { // invalid doc - throw new Error(`Invalid ${PRIVACY_POLICY_DOC_ID} doc: missing required "privacy_policies" property`); + throw new NotFoundError(`Invalid ${PRIVACY_POLICY_DOC_ID} doc: missing required "privacy_policies" property`); } return doc; }) diff --git a/api/src/services/records.js b/api/src/services/records.js index b6edb7d15a7..625994975a6 100644 --- a/api/src/services/records.js +++ b/api/src/services/records.js @@ -3,7 +3,7 @@ const phoneNumber = require('@medic/phone-number'); const config = require('../config'); const smsparser = require('./report/smsparser'); const validate = require('./report/validate'); -const PublicError = require('../public-error'); +const { PublicError } = require('../errors'); const DATE_NUMBER_STRING = /(\d{13,})/; // matches invisible characters that can mess up our parsing diff --git a/api/src/services/setup/view-indexer.js b/api/src/services/setup/view-indexer.js index 7055f867f80..6dac3ff3476 100644 --- a/api/src/services/setup/view-indexer.js +++ b/api/src/services/setup/view-indexer.js @@ -66,7 +66,6 @@ const indexView = async (dbName, ddocId, viewName) => { uri: `${environment.serverUrl}/${dbName}/${ddocId}/_view/${viewName}`, json: true, qs: { limit: 1 }, - timeout: 2000, }); } catch (requestError) { if (!continueIndexing) { diff --git a/api/src/translations.js b/api/src/translations.js index 7c2c2bd64f3..a2da98857b5 100644 --- a/api/src/translations.js +++ b/api/src/translations.js @@ -23,7 +23,8 @@ const LOCAL_NAME_MAP = { sw: 'Kiswahili (Swahili)', hi: 'हिन्दी (Hindi)', id: 'Bahasa Indonesia (Indonesian)', - lg: 'Luganda (Ganda)' + lg: 'Luganda (Ganda)', + ar: 'عربي (Arabic)' }; const extractLocaleCode = filename => { diff --git a/api/tests/mocha/services/privacy-policy.spec.js b/api/tests/mocha/services/privacy-policy.spec.js index 6bc4315ff4e..6aecab9956d 100644 --- a/api/tests/mocha/services/privacy-policy.spec.js +++ b/api/tests/mocha/services/privacy-policy.spec.js @@ -3,6 +3,7 @@ const sinon = require('sinon'); const service = require('../../../src/services/privacy-policy'); const db = require('../../../src/db'); const config = require('../../../src/config'); +const logger = require('@medic/logger'); describe('Privacy Policy service', () => { @@ -123,6 +124,27 @@ describe('Privacy Policy service', () => { }); }); + it('should throw error when doc does not exist', async () => { + sinon.spy(logger, 'error'); + sinon.stub(db.medic, 'get').rejects({ status: 404 }); + await chai.expect(service.get('sw')).to.be.rejected.and.eventually.deep.equal({ status: 404 }); + chai.expect(logger.error.called).to.equal(false); + }); + + it('should throw error when doc has no policies', async () => { + sinon.stub(db.medic, 'get').resolves({ privacy_policies: {} }); + sinon.spy(logger, 'error'); + await chai.expect(service.get('sw')).to.be.rejected.and.eventually.deep.include({ status: 404 }); + chai.expect(logger.error.called).to.equal(false); + }); + + it('should throw error on misconfigured doc', async () => { + sinon.stub(db.medic, 'get').resolves({ }); + sinon.spy(logger, 'error'); + await chai.expect(service.get('sw')).to.be.rejected.and.eventually.deep.include({ status: 404 }); + chai.expect(logger.error.called).to.equal(false); + }); + }); }); diff --git a/api/tests/mocha/services/setup/view-indexer.spec.js b/api/tests/mocha/services/setup/view-indexer.spec.js index c61787d0870..098a53601d1 100644 --- a/api/tests/mocha/services/setup/view-indexer.spec.js +++ b/api/tests/mocha/services/setup/view-indexer.spec.js @@ -6,7 +6,8 @@ const db = require('../../../../src/db'); const env = require('@medic/environment'); const request = require('@medic/couch-request'); const databases = require('../../../../src/services/setup/databases'); -const upgradeLogService = require('../../../../src/services/setup/upgrade-log'); +const upgradeLogService = require('../../../../src/service' + + 's/setup/upgrade-log'); let viewIndexer; @@ -60,38 +61,33 @@ describe('View indexer service', () => { uri: 'http://localhost/thedb/_design/:staged:one/_view/view1', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb/_design/:staged:one/_view/view2', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb/_design/:staged:one/_view/view3', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb/_design/:staged:three/_view/view4', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb-users-meta/_design/:staged:four/_view/view', json: true, qs: { limit: 1 }, - timeout: 2000, }], ]); }); }); describe('indexView', () => { - it('should query the view with a timeout', async () => { + it('should query the view', async () => { sinon.stub(request, 'get').resolves(); sinon.stub(env, 'serverUrl').value('http://localhost'); @@ -102,7 +98,6 @@ describe('View indexer service', () => { uri: 'http://localhost/medic/_design/:staged:medic/_view/contacts', json: true, qs: { limit: 1 }, - timeout: 2000, }]); }); @@ -119,7 +114,6 @@ describe('View indexer service', () => { uri: 'http://localhost/other/_design/mydesign/_view/viewname', json: true, qs: { limit: 1 }, - timeout: 2000, }; expect(request.get.args).to.deep.equal(Array.from({ length: 21 }).map(() => [params])); }); @@ -137,7 +131,6 @@ describe('View indexer service', () => { uri: 'http://localhost/other/_design/mydesign/_view/viewname', json: true, qs: { limit: 1 }, - timeout: 2000, }; expect(request.get.args).to.deep.equal(Array.from({ length: 21 }).map(() => [params])); }); diff --git a/couchdb/10-docker-default.ini b/couchdb/10-docker-default.ini index 92e42a2863e..8c924a92d54 100644 --- a/couchdb/10-docker-default.ini +++ b/couchdb/10-docker-default.ini @@ -11,6 +11,7 @@ os_process_timeout = 60000 max_dbs_open = 5000 attachment_stream_buffer_size = 16384 max_document_size = 4294967296 ; 4 GB +changes_doc_ids_optimization_threshold = 40000 [chttpd] port = 5984 diff --git a/couchdb/Dockerfile b/couchdb/Dockerfile index 2baa33c424a..2afba060d56 100644 --- a/couchdb/Dockerfile +++ b/couchdb/Dockerfile @@ -1,4 +1,4 @@ -FROM couchdb:3.3.3 as base_couchdb_build +FROM couchdb:3.4.2 as base_couchdb_build COPY --chown=couchdb:couchdb 10-docker-default.ini /opt/couchdb/etc/default.d/ COPY --chown=couchdb:couchdb vm.args /opt/couchdb/etc/ diff --git a/package-lock.json b/package-lock.json index 819b0b5e2aa..40658d10f43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "medic", - "version": "4.14.0", + "version": "4.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medic", - "version": "4.14.0", + "version": "4.15.0", "hasInstallScript": true, "license": "AGPL-3.0-only", "workspaces": [ @@ -19,7 +19,7 @@ "bowser": "^2.11.0", "buffer-shims": "^1.0.0", "cht-nootils": "^4.2.0", - "compression": "^1.7.4", + "compression": "^1.7.5", "cron-validator": "^1.3.1", "epi-week": "^0.0.1", "express": "^4.21.1", @@ -169,8 +169,8 @@ "xml-js": "^1.6.11" }, "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" } }, "node_modules/@ampproject/remapping": { @@ -14005,14 +14005,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -14461,9 +14453,9 @@ "dev": true }, "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "engines": { "node": ">= 0.8" } @@ -16344,16 +16336,16 @@ } }, "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", + "bytes": "3.1.2", + "compressible": "~2.0.18", "debug": "2.6.9", + "negotiator": "~0.6.4", "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", + "safe-buffer": "5.2.1", "vary": "~1.1.2" }, "engines": { @@ -16373,10 +16365,13 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -31150,14 +31145,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/raw-loader": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", diff --git a/package.json b/package.json index 43e9019ccce..70cd07cc139 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "medic", - "version": "4.14.0", + "version": "4.15.0", "private": true, "license": "AGPL-3.0-only", "repository": { @@ -53,7 +53,8 @@ "unit-haproxy-healthcheck": "cd haproxy-healthcheck && make test", "unit-cht-deploy": "cd scripts/deploy && npm test", "wdio-default-mobile-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/default-mobile/wdio.conf.js --suite=all", - "wdio-visual-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/visual/wdio.conf.js --suite=all", + "wdio-visual-desktop": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/visual/wdio.conf.js --suite=desktopTests", + "wdio-visual-mobile": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/visual/wdio.conf.js --suite=mobileTests", "wdio-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/default/wdio.conf.js", "apdex-test": "wdio run ./tests/performance/apdex-score/wdio.conf.js", "-- CI SCRIPTS ": "-----------------------------------------------------------------------------------------------", @@ -179,7 +180,7 @@ "bowser": "^2.11.0", "buffer-shims": "^1.0.0", "cht-nootils": "^4.2.0", - "compression": "^1.7.4", + "compression": "^1.7.5", "cron-validator": "^1.3.1", "epi-week": "^0.0.1", "express": "^4.21.1", diff --git a/scripts/build/versions.js b/scripts/build/versions.js index a1ed5be17eb..31128aeb1a2 100644 --- a/scripts/build/versions.js +++ b/scripts/build/versions.js @@ -1,5 +1,5 @@ const packageJson = require('../../package.json'); -const buildTime = new Date().getTime(); +const { execSync } = require('child_process'); const { ECR_REPO, @@ -46,7 +46,7 @@ const getVersion = (release) => { return process.env.VERSION; } - return `${packageJson.version}-dev.${buildTime}`; + return execSync('git branch --show-current', { encoding: 'utf-8' }).trim(); }; const getImageTag = (service, release = false) => { diff --git a/scripts/docker-helper-4.x/cht-docker-compose.sh b/scripts/docker-helper-4.x/cht-docker-compose.sh index 7c56cc8602b..1cc3baccbdc 100755 --- a/scripts/docker-helper-4.x/cht-docker-compose.sh +++ b/scripts/docker-helper-4.x/cht-docker-compose.sh @@ -398,7 +398,7 @@ if [[ -z "$projectName" ]]; then echo # thanks for the pr vs rp!! https://unix.stackexchange.com/a/677805 - read -rp "Would you like to initialize a new project [y/N]?" yn + read -rp "Would you like to initialize a new project [y/N]? " yn case $yn in [Yy]*) while [[ -z "$projectName" ]]; do @@ -408,10 +408,19 @@ if [[ -z "$projectName" ]]; then case $runLatest in [nN]*) allKnownVersions=$(get_all_known_versions) + optionCount=$(wc -l <<< "$allKnownVersions") echo - echo "Which version to you want to run? (ctrl + c to quit)" - select preferredRelease in $allKnownVersions; do - break + echo "Select a version by entering 1 through ${optionCount} or 'ctrl + c' to quit: " + while true; do + select preferredRelease in $allKnownVersions; do + if [[ "$REPLY" =~ ^[0-9]+$ && "$REPLY" -gt 0 && "$REPLY" -lt $optionCount ]]; then + echo + echo "Selected version ${preferredRelease}"; + break 2; + else + echo "Invalid choice. Enter 1 through ${optionCount} or 'ctrl + c' to quit" + fi + done done esac echo diff --git a/sentinel/server.js b/sentinel/server.js index e2c62fa7807..df5057a732f 100755 --- a/sentinel/server.js +++ b/sentinel/server.js @@ -35,20 +35,26 @@ const waitForApi = () => new Promise(resolve => { }); logger.info('Running server checks...'); -serverChecks - .check(environment.couchUrl) - .then(waitForApi) - .then(() => { - // Even requiring this boots translations, so has to be required after - // api has booted + +(async () => { + try { + await serverChecks.check(environment.couchUrl); + await waitForApi(); + const config = require('./src/config'); - return config.init().then(() => { - require('./src/schedule').init(); - logger.info('startup complete.'); - }); - }) - .catch(err => { - logger.error('Fatal error intialising sentinel'); + await config.init(); + + const schedule = require('./src/schedule'); + schedule.init(); + + logger.info('startup complete.'); + + const processHooks = require('./src/process-hooks'); + processHooks.init(); + + } catch (err) { + logger.error('Fatal error initialising sentinel'); logger.error('%o', err); process.exit(1); - }); + } +})(); diff --git a/sentinel/src/lib/feed.js b/sentinel/src/lib/feed.js index bbd93562450..e688802d1cd 100644 --- a/sentinel/src/lib/feed.js +++ b/sentinel/src/lib/feed.js @@ -15,6 +15,7 @@ const MAX_QUEUE_SIZE = 100; let request; let processed = 0; +let processing; const enqueue = change => changeQueue.push(change); @@ -100,33 +101,34 @@ changeQueue.drain(() => { resumeProcessing(); }); +/** + * Start listening from the last processed seq. Will restart + * automatically on error. + */ const listen = () => { + logger.info('Starting transition processing'); + processing = true; changeQueue.resume(); return resumeProcessing(); }; -module.exports = { +/** + * Stops listening for changes. Must be restarted manually + * by calling listen. + */ +const cancel = () => { + logger.info('Suspending transition processing'); + processing = false; + changeQueue.pause(); + if (request) { + request.cancel && request.cancel(); + request = null; + } +}; - /** - * Start listening from the last processed seq. Will restart - * automatically on error. - */ +module.exports = { listen, + cancel, - /** - * Stops listening for changes. Must be restarted manually - * by calling listen. - */ - cancel: () => { - changeQueue.pause(); - if (request) { - request.cancel && request.cancel(); - request = null; - } - }, - - // exposed for testing - _changeQueue: changeQueue, - _transitionsLib: transitionsLib, - _enqueue: enqueue, + toggle: () => processing ? cancel() : listen() }; diff --git a/sentinel/src/process-hooks.js b/sentinel/src/process-hooks.js new file mode 100644 index 00000000000..d14e088ea68 --- /dev/null +++ b/sentinel/src/process-hooks.js @@ -0,0 +1,13 @@ +const feed = require('./lib/feed'); +const schedule = require('./schedule'); + +module.exports = { + init: () => { + process.on('SIGUSR1', () => { + feed.toggle(); + }); + process.on('SIGUSR2', () => { + schedule.runTasks(); + }); + } +}; diff --git a/sentinel/src/schedule/index.js b/sentinel/src/schedule/index.js index 928d8abe8bb..c717949bd32 100644 --- a/sentinel/src/schedule/index.js +++ b/sentinel/src/schedule/index.js @@ -72,3 +72,4 @@ exports.init = () => { runTasks(); interval = setInterval(runTasks, RUN_EVERY_MS); }; +exports.runTasks = runTasks; diff --git a/sentinel/src/transitions.js b/sentinel/src/transitions.js index 5a5758a90d0..b0e56fd2a68 100644 --- a/sentinel/src/transitions.js +++ b/sentinel/src/transitions.js @@ -20,8 +20,5 @@ module.exports = { /** * Loads the transitions and starts watching for db changes. */ - loadTransitions: loadTransitions, - - // exposed for testing - _transitionsLib: transitionsLib, + loadTransitions, }; diff --git a/sentinel/tests/unit/lib/feed.spec.js b/sentinel/tests/unit/lib/feed.spec.js index 3481a15ad84..d571a029509 100644 --- a/sentinel/tests/unit/lib/feed.spec.js +++ b/sentinel/tests/unit/lib/feed.spec.js @@ -3,9 +3,9 @@ config.initTransitionLib(); const sinon = require('sinon'); const chai = require('chai'); -const assert = require('chai').assert; +const rewire = require('rewire'); + const db = require('../../../src/db'); -const feed = require('../../../src/lib/feed'); const metadata = require('../../../src/lib/metadata'); const logger = require('@medic/logger'); const tombstoneUtils = require('@medic/tombstone-utils'); @@ -14,6 +14,12 @@ const changeRetryHistory = require('../../../src/lib/change-retry-history'); describe('feed', () => { let handler; + let feed; + let changeQueue; + const realSetTimeout = setTimeout; + const nextTick = () => new Promise(resolve => realSetTimeout(() => resolve())); + + let clock; beforeEach(() => { handler = { @@ -22,7 +28,10 @@ describe('feed', () => { handler.catch = sinon.stub().returns(handler); handler.on = sinon.stub().returns(handler); sinon.stub(db.medic, 'changes').returns(handler); - feed._changeQueue.resume(); + clock = sinon.useFakeTimers(); + feed = rewire('../../../src/lib/feed'); + changeQueue = feed.__get__('changeQueue'); + changeQueue.resume(); }); afterEach(() => { @@ -61,15 +70,12 @@ describe('feed', () => { }); it('restarts listener after db error', () => { - const realSetTimeout = setTimeout; - const nextTick = () => new Promise(resolve => realSetTimeout(() => resolve())); - const clock = sinon.useFakeTimers(); const change = { id: 'some-uuid' }; sinon.stub(metadata, 'getTransitionSeq') .onCall(0).resolves('123') .onCall(1).resolves('456'); - const push = sinon.stub(feed._changeQueue, 'push'); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() .then(() => { @@ -106,8 +112,9 @@ describe('feed', () => { const change = { id: 'some-uuid' }; sinon.stub(metadata, 'getTransitionSeq').resolves('123'); sinon.stub(tombstoneUtils, 'isTombstoneId').returns(false); - sinon.stub(feed._changeQueue, 'length').returns(0); - const push = sinon.stub(feed._changeQueue, 'push'); + + sinon.stub(changeQueue, 'length').returns(0); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() @@ -126,8 +133,8 @@ describe('feed', () => { const edoc = { id: 'some-uuid' }; sinon.stub(metadata, 'getTransitionSeq').resolves('123'); sinon.stub(tombstoneUtils, 'isTombstoneId').returns(false); - sinon.stub(feed._changeQueue, 'length').returns(0); - const push = sinon.stub(feed._changeQueue, 'push'); + sinon.stub(changeQueue, 'length').returns(0); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() .then(() => { @@ -147,7 +154,7 @@ describe('feed', () => { sinon.stub(metadata, 'getTransitionSeq').resolves('123'); sinon.stub(tombstoneUtils, 'isTombstoneId').returns(false); - const push = sinon.stub(feed._changeQueue, 'push'); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() .then(() => { @@ -169,7 +176,7 @@ describe('feed', () => { .withArgs(tombstone.id).returns(true) .withArgs(doc.id).returns(false); - const push = sinon.stub(feed._changeQueue, 'push'); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() .then(() => { @@ -192,7 +199,7 @@ describe('feed', () => { .withArgs(doc1).returns(false) .withArgs(doc2).returns(true); - const push = sinon.stub(feed._changeQueue, 'push'); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() .then(() => { @@ -211,8 +218,8 @@ describe('feed', () => { sinon.stub(metadata, 'getTransitionSeq').resolves('123'); sinon.stub(tombstoneUtils, 'isTombstoneId').returns(false); - sinon.stub(feed._changeQueue, 'length').returns(101); - const push = sinon.stub(feed._changeQueue, 'push'); + sinon.stub(changeQueue, 'length').returns(101); + const push = sinon.stub(changeQueue, 'push'); return feed .listen() @@ -232,8 +239,8 @@ describe('feed', () => { sinon.stub(metadata, 'getTransitionSeq').resolves('123'); sinon.stub(tombstoneUtils, 'isTombstoneId').returns(false); const change = { id: 'some-uuid' }; - sinon.stub(feed._changeQueue, 'push'); - sinon.spy(feed._changeQueue, 'resume'); + sinon.stub(changeQueue, 'push'); + sinon.spy(changeQueue, 'resume'); return feed .listen() @@ -242,9 +249,9 @@ describe('feed', () => { callbackFn(change); }) .then(() => { - chai.expect(feed._changeQueue.resume.callCount).to.equal(1); - chai.expect(feed._changeQueue.push.callCount).to.equal(1); - chai.expect(feed._changeQueue.push.args[0][0]).to.deep.equal(change); + chai.expect(changeQueue.resume.callCount).to.equal(1); + chai.expect(changeQueue.push.callCount).to.equal(1); + chai.expect(changeQueue.push.args[0][0]).to.deep.equal(change); }); }); }); @@ -253,16 +260,17 @@ describe('feed', () => { it('cancels the couch request', () => { sinon.stub(metadata, 'getTransitionSeq').resolves('123'); - - const push = sinon.stub(feed._changeQueue, 'push'); + const changeQueue = feed.__get__('changeQueue'); + sinon.stub(changeQueue, 'push'); + const change = { id: 'some-uuid' }; - sinon.spy(feed._changeQueue, 'pause'); + sinon.spy(changeQueue, 'pause'); return feed .listen() .then(() => feed.cancel()) .then(() => { - chai.expect(feed._changeQueue.pause.callCount).to.equal(1); + chai.expect(changeQueue.pause.callCount).to.equal(1); chai.expect(handler.cancel.callCount).to.equal(1); // resume listening return feed.listen(); @@ -272,32 +280,53 @@ describe('feed', () => { callbackFn(change); }) .then(() => { - chai.expect(push.callCount).to.equal(1); - chai.expect(push.args[0][0]).to.deep.equal(change); + chai.expect(changeQueue.push.callCount).to.equal(1); + chai.expect(changeQueue.push.args[0][0]).to.deep.equal(change); }); }); }); + describe('toggle', () => { + beforeEach(() => { + feed.__set__('cancel', sinon.stub()); + feed.__set__('listen', sinon.stub()); + }); + + it('should stop feed if started', () => { + feed.__set__('processing', true); + feed.toggle(); + chai.expect(feed.__get__('cancel').called).to.equal(true); + chai.expect(feed.__get__('listen').called).to.equal(false); + }); + + it('should start feed if stopped', () => { + feed.toggle(); + chai.expect(feed.__get__('cancel').called).to.equal(false); + chai.expect(feed.__get__('listen').called).to.equal(true); + }); + }); + describe('changeQueue', () => { it('should not resume feed if drain happens while queue is paused', (done) => { let resolvePromise; const delayedPromise = new Promise(resolve => resolvePromise = resolve); - sinon.stub(feed._transitionsLib, 'processChange').callsFake((change, cb) => { + const transitionsLib = feed.__get__('transitionsLib'); + sinon.stub(transitionsLib, 'processChange').callsFake((change, cb) => { return delayedPromise.then(() => cb()); }); sinon.spy(logger, 'debug'); - sinon.spy(feed._changeQueue, 'resume'); + sinon.spy(changeQueue, 'resume'); sinon.stub(metadata, 'getTransitionSeq').resolves(); sinon.stub(metadata, 'setTransitionSeq').resolves(); - feed._enqueue({ id: 'somechange', seq: 65558 }); - feed._changeQueue.process(); + feed.__get__('enqueue')({ id: 'somechange', seq: 65558 }); + changeQueue.process(); feed.cancel(); // feed is now canceled resolvePromise(); // queue is now drained - setTimeout(() => { + realSetTimeout(() => { chai.expect(logger.debug.withArgs('transitions: queue drained').callCount).to.equal(1); - chai.expect(feed._changeQueue.resume.callCount).to.equal(0); + chai.expect(changeQueue.resume.callCount).to.equal(0); chai.expect(metadata.setTransitionSeq.callCount).to.equal(1); chai.expect(metadata.setTransitionSeq.args[0]).to.deep.equal([65558]); chai.expect(metadata.getTransitionSeq.callCount).to.equal(1); // we get the seq anyway @@ -307,20 +336,19 @@ describe('feed', () => { }); it('handles an empty change', done => { - sinon.stub(feed._changeQueue, 'length').returns(0); + sinon.stub(changeQueue, 'length').returns(0); sinon.stub(metadata, 'setTransitionSeq').resolves(); - sinon.stub(feed._transitionsLib, 'processChange').callsArgWith(1); + const transitionsLib = feed.__get__('transitionsLib'); + sinon.stub(transitionsLib, 'processChange').callsArgWith(1); - feed._enqueue(); + feed.__get__('enqueue')(); - feed._changeQueue.drain(() => { - return Promise.resolve().then(() => { - assert.equal(feed._transitionsLib.processChange.callCount, 0); - return Promise.resolve().then(() => { - assert.equal(metadata.setTransitionSeq.callCount, 0); - done(); - }); - }); + changeQueue.drain(async () => { + await Promise.resolve(); + chai.expect(transitionsLib.processChange.called).to.equal(false); + await Promise.resolve(); + chai.expect(metadata.setTransitionSeq.called).to.equal(false); + done(); }); }); @@ -328,33 +356,36 @@ describe('feed', () => { sinon.stub(metadata, 'setTransitionSeq').resolves(); sinon.stub(db, 'allDbs').resolves([]); - feed._enqueue({ id: 'somechange', seq: 55, deleted: true }); + feed.__get__('enqueue')({ id: 'somechange', seq: 55, deleted: true }); - feed._changeQueue.drain(() => { - return Promise.resolve().then(() => { - assert.equal(metadata.setTransitionSeq.callCount, 1); - assert.equal(metadata.setTransitionSeq.args[0][0], 55); - done(); - }); + changeQueue.drain(async () => { + await Promise.resolve(); + + chai.expect(metadata.setTransitionSeq.calledOnce).to.equal(true); + chai.expect(metadata.setTransitionSeq.args[0][0]).to.deep.equal(55); + done(); }); }); it('runs transitions lib over changes', done => { sinon.stub(metadata, 'setTransitionSeq').resolves(); - sinon.stub(feed._transitionsLib, 'processChange').callsArgWith(1); - - feed._enqueue({ id: 'somechange', seq: 55 }); - - feed._changeQueue.drain(() => { - return Promise.resolve().then(() => { - assert.equal(feed._transitionsLib.processChange.callCount, 1); - assert.deepEqual(feed._transitionsLib.processChange.args[0][0], { id: 'somechange', seq: 55 }); - return Promise.resolve().then(() => { - assert.equal(metadata.setTransitionSeq.callCount, 1); - assert.equal(metadata.setTransitionSeq.args[0][0], 55); - done(); - }); - }); + sinon.stub(feed.__get__('transitionsLib'), 'processChange').callsArgWith(1); + + feed.__get__('enqueue')({ id: 'somechange', seq: 55 }); + + changeQueue.drain(async () => { + await Promise.resolve(); + + chai.expect(feed.__get__('transitionsLib').processChange.calledOnce).to.equal(true); + chai.expect(feed.__get__('transitionsLib').processChange.args[0][0]) + .to.deep.equal({ id: 'somechange', seq: 55 }); + + await Promise.resolve(); + + chai.expect(metadata.setTransitionSeq.calledOnce).to.equal(true); + chai.expect(metadata.setTransitionSeq.args[0][0]).to.deep.equal(55); + + done(); }); }); }); diff --git a/sentinel/tests/unit/transitions.spec.js b/sentinel/tests/unit/transitions.spec.js index 6470ee15e33..1413b7f6538 100644 --- a/sentinel/tests/unit/transitions.spec.js +++ b/sentinel/tests/unit/transitions.spec.js @@ -1,28 +1,43 @@ const config = require('../../src/config'); config.initTransitionLib(); +const rewire = require('rewire'); const sinon = require('sinon'); -const assert = require('chai').assert; -const transitions = require('../../src/transitions'); +const expect = require('chai').expect; const feed = require('../../src/lib/feed'); +let transitions; describe('transitions', () => { afterEach(() => { sinon.restore(); }); + beforeEach(() => { + transitions = rewire('../../src/transitions'); + sinon.stub(feed, 'listen'); + sinon.stub(feed, 'cancel'); + }); + describe('loadTransitions', () => { + it('Should load all transitions', () => { + const transitionsLib = { loadTransitions: sinon.stub() }; + transitions.__set__('transitionsLib', transitionsLib); - it('cancel is called when load throws', () => { - const load = sinon.stub(transitions._transitionsLib, 'loadTransitions').throws(); - const listen = sinon.stub(feed, 'listen'); - const cancel = sinon.stub(feed, 'cancel'); transitions.loadTransitions(); - assert.equal(load.callCount, 1); - assert.equal(listen.callCount, 0); - assert.equal(cancel.callCount, 1); + expect(transitionsLib.loadTransitions.calledOnce).to.equal(true); + expect(feed.listen.called).to.equal(true); + expect(feed.cancel.calledOnce).to.equal(false); }); - }); + it('cancel is called when load throws', () => { + const transitionsLib = { loadTransitions: sinon.stub().throws() }; + transitions.__set__('transitionsLib', transitionsLib); + transitions.loadTransitions(); + + expect(transitionsLib.loadTransitions.calledOnce).to.equal(true); + expect(feed.listen.called).to.equal(false); + expect(feed.cancel.calledOnce).to.equal(true); + }); + }); }); diff --git a/shared-libs/rules-engine/src/pouchdb-provider.js b/shared-libs/rules-engine/src/pouchdb-provider.js index f560c60cb7b..512a6433ba1 100644 --- a/shared-libs/rules-engine/src/pouchdb-provider.js +++ b/shared-libs/rules-engine/src/pouchdb-provider.js @@ -52,15 +52,14 @@ const medicPouchProvider = db => { .then(([contactDocs, reportDocs, taskDocs]) => ({ contactDocs, reportDocs, taskDocs, userSettingsId })); }, - contactsBySubjectId: subjectIds => { + contactsBySubjectId: async (subjectIds) => { const keys = subjectIds.map(key => ['shortcode', key]); - return dbQuery('medic-client/contacts_by_reference', { keys, include_docs: true }) - .then(results => { - const shortcodeIds = results.rows.map(result => result.doc._id); - const idsThatArentShortcodes = subjectIds.filter(id => !results.rows.map(row => row.key[1]).includes(id)); + const results = await db.query('medic-client/contacts_by_reference', { keys, include_docs: true }); - return [...shortcodeIds, ...idsThatArentShortcodes]; - }); + const shortcodeIds = results.rows.map(result => result.doc._id); + const idsThatArentShortcodes = subjectIds.filter(id => !results.rows.map(row => row.key[1]).includes(id)); + + return [...shortcodeIds, ...idsThatArentShortcodes]; }, stateChangeCallback: docUpdateClosure(db), @@ -82,6 +81,7 @@ const medicPouchProvider = db => { reporting_period: docTag, }; } + throw err; }) .then(existingDoc => { if (!updatedTargets && existingDoc._rev) { @@ -125,40 +125,35 @@ const medicPouchProvider = db => { return rowsOf(dbQuery( 'medic-client/tasks_by_contact', options)); }, - taskDataFor: (contactIds, userSettingsDoc) => { + taskDataFor: async (contactIds, userSettingsDoc) => { if (!contactIds || contactIds.length === 0) { - return Promise.resolve({}); + return {}; } - return docsOf(db.allDocs({ keys: contactIds, include_docs: true })) - .then(contactDocs => { - const subjectIds = contactDocs.reduce((agg, contactDoc) => { - registrationUtils.getSubjectIds(contactDoc).forEach(subjectId => agg.add(subjectId)); - return agg; - }, new Set(contactIds)); - - const keys = Array.from(subjectIds); - return Promise - .all([ - docsOf(dbQuery('medic-client/reports_by_subject', { keys, include_docs: true })), - self.tasksByRelation(contactIds, 'requester'), - ]) - .then(([reportDocs, taskDocs]) => { - // tighten the connection between reports and contacts - // a report will only be allowed to generate tasks for a single contact! - reportDocs = reportDocs.filter(report => { - const subjectId = registrationUtils.getSubjectId(report); - return subjectIds.has(subjectId); - }); - - return { - userSettingsId: userSettingsDoc?._id, - contactDocs, - reportDocs, - taskDocs, - }; - }); - }); + const contactDocs = await docsOf(db.allDocs({ keys: contactIds, include_docs: true })); + const subjectIds = contactDocs.reduce((agg, contactDoc) => { + registrationUtils.getSubjectIds(contactDoc).forEach(subjectId => agg.add(subjectId)); + return agg; + }, new Set(contactIds)); + + const keys = Array.from(subjectIds); + + const [reportDocs, taskDocs] = await Promise.all([ + docsOf(dbQuery('medic-client/reports_by_subject', { keys, include_docs: true })), + self.tasksByRelation(contactIds, 'requester'), + ]); + + const relevantReportDocs = reportDocs.filter(report => { + const subjectId = registrationUtils.getSubjectId(report); + return subjectIds.has(subjectId); + }); + + return { + userSettingsId: userSettingsDoc?._id, + contactDocs, + reportDocs: relevantReportDocs, + taskDocs, + }; }, }; diff --git a/shared-libs/rules-engine/src/provider-wireup.js b/shared-libs/rules-engine/src/provider-wireup.js index 09812430b05..3747e5233f2 100644 --- a/shared-libs/rules-engine/src/provider-wireup.js +++ b/shared-libs/rules-engine/src/provider-wireup.js @@ -12,7 +12,6 @@ const refreshRulesEmissions = require('./refresh-rules-emissions'); const rulesEmitter = require('./rules-emitter'); const rulesStateStore = require('./rules-state-store'); const updateTemporalStates = require('./update-temporal-states'); -const calendarInterval = require('@medic/calendar-interval'); let wireupOptions; @@ -31,7 +30,7 @@ module.exports = { * @param {number} settings.monthStartDate reporting interval start date * @param {Object} userDoc User's hydrated contact document */ - initialize: (provider, settings) => { + initialize: async (provider, settings) => { const isEnabled = rulesEmitter.initialize(settings); if (!isEnabled) { return Promise.resolve(); @@ -40,26 +39,21 @@ module.exports = { const { enableTasks=true, enableTargets=true } = settings; wireupOptions = { enableTasks, enableTargets }; - return provider - .existingRulesStateStore() - .then(existingStateDoc => { - if (!rulesEmitter.isLatestNoolsSchema()) { - throw Error('Rules Engine: Updates to the nools schema are required'); - } + const existingStateDoc = await provider.existingRulesStateStore(); + if (!rulesEmitter.isLatestNoolsSchema()) { + throw Error('Rules Engine: Updates to the nools schema are required'); + } - const contactClosure = updatedState => provider.stateChangeCallback( - existingStateDoc, - { rulesStateStore: updatedState } - ); - const needsBuilding = rulesStateStore.load(existingStateDoc.rulesStateStore, settings, contactClosure); - return handleIntervalTurnover(provider, settings).then(() => { - if (!needsBuilding) { - return; - } - - rulesStateStore.build(settings, contactClosure); - }); - }); + const contactClosure = updatedState => provider.stateChangeCallback( + existingStateDoc, + { rulesStateStore: updatedState } + ); + const needsBuilding = rulesStateStore.load(existingStateDoc.rulesStateStore, settings, contactClosure); + if (!needsBuilding) { + return; + } + + rulesStateStore.build(settings, contactClosure); }, /** @@ -273,20 +267,18 @@ const refreshRulesEmissionForContacts = (provider, calculationTimestamp, contact }); }; - return handleIntervalTurnover(provider, { monthStartDate: rulesStateStore.getMonthStartDate() }).then(() => { - if (contactIds) { - return refreshForKnownContacts(calculationTimestamp, contactIds); - } + if (contactIds) { + return refreshForKnownContacts(calculationTimestamp, contactIds); + } - // If the contact state store does not contain all contacts, build up that list (contact doc ids + headless ids in - // reports/tasks) - if (!rulesStateStore.hasAllContacts()) { - return refreshForAllContacts(calculationTimestamp); - } + // If the contact state store does not contain all contacts, build up that list (contact doc ids + headless ids in + // reports/tasks) + if (!rulesStateStore.hasAllContacts()) { + return refreshForAllContacts(calculationTimestamp); + } - // Once the contact state store has all contacts, trust it and only refresh those marked dirty - return refreshForKnownContacts(calculationTimestamp, rulesStateStore.getContactIds()); - }); + // Once the contact state store has all contacts, trust it and only refresh those marked dirty + return refreshForKnownContacts(calculationTimestamp, rulesStateStore.getContactIds()); }; const storeTargetsDoc = (provider, aggregate, updatedTargets) => { @@ -305,28 +297,3 @@ const storeTargetsDoc = (provider, aggregate, updatedTargets) => { ); }; -// This function takes the last saved state (which may be stale) and generates the targets doc for the corresponding -// reporting interval (that includes the date when the state was calculated). -// We don't recalculate the state prior to this because we support targets that count events infinitely to emit `now`, -// which means that they would all be excluded from the emission filter (being outside the past reporting interval). -// https://github.com/medic/cht-core/issues/6209 -const handleIntervalTurnover = async (provider, { monthStartDate }) => { - if (!rulesStateStore.isLoaded() || !wireupOptions.enableTargets) { - return Promise.resolve(); - } - - const stateCalculatedAt = rulesStateStore.stateLastUpdatedAt(); - if (!stateCalculatedAt) { - return Promise.resolve(); - } - - const currentInterval = calendarInterval.getCurrent(monthStartDate); - // 4th parameter of isBetween represents inclusivity. By default or using ( is exclusive, [ is inclusive - if (moment(stateCalculatedAt).isBetween(currentInterval.start, currentInterval.end, null, '[]')) { - return Promise.resolve(); - } - - const filterInterval = calendarInterval.getInterval(monthStartDate, stateCalculatedAt); - const aggregate = await rulesStateStore.getTargetAggregates(filterInterval); - return storeTargetsDoc(provider, aggregate, true); -}; diff --git a/shared-libs/rules-engine/test/integration.spec.js b/shared-libs/rules-engine/test/integration.spec.js index 155bbbf53fd..d6d4874c646 100644 --- a/shared-libs/rules-engine/test/integration.spec.js +++ b/shared-libs/rules-engine/test/integration.spec.js @@ -9,7 +9,6 @@ const sinon = require('sinon'); const RulesEngine = require('../src'); const rulesEmitter = require('../src/rules-emitter'); -const calendarInterval = require('@medic/calendar-interval'); const { expect } = chai; chai.use(chaiExclude); @@ -259,18 +258,10 @@ describe(`Rules Engine Integration Tests`, () => { clock.setSystemTime(TEST_START + MS_IN_DAY * 39); const monthLater = await rulesEngine.fetchTasksFor(['patient']); expect(monthLater).to.have.property('length', 0); - expect(db.bulkDocs.callCount).to.eq(5); - - // interval turnover - expect(db.bulkDocs.args[3][0].docs[0]).to.deep.include({ - _id: `target~${TARGET_INTERVAL}~user~org.couchdb.user:username`, - type: 'target', - owner: 'user', - reporting_period: TARGET_INTERVAL, - }); + expect(db.bulkDocs.callCount).to.eq(4); const dateNext = moment(TEST_START + MS_IN_DAY * 39).format('YYYY-MM'); - expect(db.bulkDocs.args[4][0].docs[0]).to.deep.include({ + expect(db.bulkDocs.args[3][0].docs[0]).to.deep.include({ _id: `target~${dateNext}~user~org.couchdb.user:username`, type: 'target', owner: 'user', @@ -692,70 +683,6 @@ describe(`Rules Engine Integration Tests`, () => { pass: 1, }); }); - - it('targets on interval turnover only recalculates targets when interval changes', async () => { - const targetsSaved = () => { - const targets = []; - db.bulkDocs.args.forEach(([docs]) => { - if (!docs) { - return; - } - - if (docs && docs.docs) { - docs = docs.docs; - } - docs.forEach(doc => doc._id.startsWith('target') && targets.push(doc)); - }); - - return targets; - }; - - clock.setSystemTime(TEST_START); - const patientContact2 = Object.assign({}, patientContact, { _id: 'patient2', patient_id: 'patient_id2', }); - const pregnancyRegistrationReport2 = Object.assign( - {}, - pregnancyRegistrationReport, - { - _id: 'pregReg2', - fields: { lmp_date_8601: TEST_START, patient_id: patientContact2.patient_id }, - reported_date: TEST_START+1 - }, - ); - await db.bulkDocs([patientContact, patientContact2, pregnancyRegistrationReport, pregnancyRegistrationReport2]); - await rulesEngine.updateEmissionsFor(['patient']); - // we're in THE_FUTURE and our state is fresh - - sinon.spy(db, 'bulkDocs'); - sinon.spy(db, 'query'); - const targets = await fetchTargets(); - expect(db.query.callCount).to.eq(expectedQueriesForAllFreshData.length); - expect(targets[['pregnancy-registrations-this-month']].value).to.deep.eq({ - total: 2, - pass: 2, - }); - expect(targetsSaved().length).to.equal(1); - - const sameTargets = await fetchTargets(); - expect(db.query.callCount).to.eq(expectedQueriesForAllFreshData.length); - expect(sameTargets).to.deep.eq(targets); - expect(targetsSaved().length).to.equal(1); - - // fast forward one month - clock.tick(moment(TEST_START).add(1, 'month').diff(moment(TEST_START)) + 2); - const newTargets = await fetchTargets(calendarInterval.getCurrent()); - expect(newTargets[['pregnancy-registrations-this-month']].value).to.deep.eq({ - total: 0, - pass: 0, - }); - const savedTargets = targetsSaved(); - expect(savedTargets.length).to.equal(3); - - const firstTargetInterval = calendarInterval.getInterval(1, TEST_START); - const secondTargetInterval = calendarInterval.getCurrent(); - expect(savedTargets[0].reporting_period).to.equal(moment(firstTargetInterval.end).format('YYYY-MM')); - expect(savedTargets[1].reporting_period).to.equal(moment(firstTargetInterval.end).format('YYYY-MM')); - expect(savedTargets[2].reporting_period).to.equal(moment(secondTargetInterval.end).format('YYYY-MM')); - }); }); } diff --git a/shared-libs/rules-engine/test/pouchdb-provider.spec.js b/shared-libs/rules-engine/test/pouchdb-provider.spec.js index 3dbbd95d1b3..e5e0539a4a0 100644 --- a/shared-libs/rules-engine/test/pouchdb-provider.spec.js +++ b/shared-libs/rules-engine/test/pouchdb-provider.spec.js @@ -254,6 +254,29 @@ describe('pouchdb provider', () => { expect(secondTargetDoc._rev).not.to.equal(firstTargetDoc._rev); }); + it('should throw pouchdb get errors', async () => { + const docTag = '2024-07'; + const nextTargets = [{ id: 'target', score: 1 }]; + + sinon.stub(db, 'get').rejects(new Error('pouch crash')); + + await expect( + pouchdbProvider(db).commitTargetDoc(nextTargets, docTag, { userContactDoc, userSettingsDoc }) + ).to.eventually.be.rejectedWith('pouch crash'); + }); + + it('should throw pouchdb put errors', async () => { + const docTag = '2024-07'; + const nextTargets = [{ id: 'target', score: 1 }]; + + sinon.restore(); + sinon.stub(db, 'put').rejects(new Error('pouch crash')); + + await expect( + pouchdbProvider(db).commitTargetDoc(nextTargets, docTag, { userContactDoc, userSettingsDoc }) + ).to.eventually.be.rejectedWith('pouch crash'); + }); + }); describe('contactsBySubjectId', () => { diff --git a/shared-libs/rules-engine/test/provider-wireup.spec.js b/shared-libs/rules-engine/test/provider-wireup.spec.js index 13fa39bf8a8..f0afc81dbba 100644 --- a/shared-libs/rules-engine/test/provider-wireup.spec.js +++ b/shared-libs/rules-engine/test/provider-wireup.spec.js @@ -747,7 +747,7 @@ describe('provider-wireup integration tests', () => { expect(provider.commitTargetDoc.callCount).to.equal(0); }); - it('should update the targets doc when the state was calculated outside of the interval', async () => { + it('should not update the targets doc when the state was calculated outside of the interval', async () => { clock.setSystemTime(moment('2020-04-28').valueOf()); const rules = simpleNoolsTemplate(''); const settings = { @@ -779,13 +779,7 @@ describe('provider-wireup integration tests', () => { clock.setSystemTime(moment('2020-05-02').valueOf()); // next interval await wireup.initialize(provider, settings, {}); - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0]).to.deep.equal([ - [{ id: 'uhc', value: { pass: 1, total: 2 } }], - '2020-04', - { userSettingsDoc: { _id: 'org.couchdb.user:username' }, userContactDoc: { _id: 'mock_user_id' }}, - true, - ]); + expect(provider.commitTargetDoc.called).to.be.false; }); it('should work when the settings have been changed', async () => { @@ -824,13 +818,7 @@ describe('provider-wireup integration tests', () => { clock.setSystemTime(moment('2020-04-28').valueOf()); // next interval await wireup.initialize(provider, settings, {}); - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0]).to.deep.equal([ - [{ id: 'uhc', value: { pass: 2, total: 2 } }], - '2020-04', - { userSettingsDoc: { _id: 'org.couchdb.user:username' }, userContactDoc: { _id: 'mock_user_id' } }, - true, - ]); + expect(provider.commitTargetDoc.called).to.be.false; }); it('should work with old format of the rules state store', async () => { @@ -869,13 +857,7 @@ describe('provider-wireup integration tests', () => { clock.setSystemTime(moment('2020-04-28').valueOf()); // next interval await wireup.initialize(provider, settings, {}); - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0]).to.deep.equal([ - [{ id: 'uhc', value: { pass: 2, total: 2 } }], - '2020-04', - { userSettingsDoc: { _id: 'org.couchdb.user:username' }, userContactDoc: { _id: 'mock_user_id' } }, - true, - ]); + expect(provider.commitTargetDoc.called).to.be.false; }); it('should use inclusive operator when comparing dates (left)', async () => { @@ -909,13 +891,7 @@ describe('provider-wireup integration tests', () => { clock.setSystemTime(moment('2020-06-02').valueOf()); // next interval await wireup.initialize(provider, settings, {}); - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0]).to.deep.equal([ - [{ id: 'uhc', value: { pass: 2, total: 2 } }], - '2020-05', - { userSettingsDoc: { _id: 'org.couchdb.user:username' }, userContactDoc: { _id: 'mock_user_id' } }, - true, - ]); + expect(provider.commitTargetDoc.called).to.be.false; }); it('should use inclusive operator when comparing dates (right)', async () => { @@ -950,13 +926,7 @@ describe('provider-wireup integration tests', () => { clock.setSystemTime(moment('2020-06-02').valueOf()); // next interval await wireup.initialize(provider, settings, {}); - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0]).to.deep.equal([ - [{ id: 'uhc', value: { pass: 2, total: 2 } }], - '2020-05', - { userSettingsDoc: { _id: 'org.couchdb.user:username' }, userContactDoc: { _id: 'mock_user_id' } }, - true, - ]); + expect(provider.commitTargetDoc.called).to.be.false; }); }); @@ -995,100 +965,6 @@ describe('provider-wireup integration tests', () => { expect(provider.commitTargetDoc.args[1][1]).to.equal('2020-04'); expect(provider.commitTargetDoc.args[1][0]).to.deep.equal([{ id: 'uhc', value: { pass: 2, total: 2 }}]); }); - - it('should update targets when in new interval when refreshing tasks', async () => { - const rules = simpleNoolsTemplate(''); - const settings = { - rules, - enableTargets: true, - targets: [{ - id: 'uhc', - }], - monthStartDate: 1, - }; - - clock.setSystemTime(moment('2020-04-30 23:00:00').valueOf()); - await wireup.initialize(provider, settings, {}); - - const emissions = [ - mockTargetEmission('uhc', 'doc4', moment('2020-02-23').valueOf(), true), // passes outside interval - mockTargetEmission('uhc', 'doc2', moment('2020-03-29').valueOf(), true), // passes outside interval - mockTargetEmission('uhc', 'doc1', moment('2020-04-12').valueOf(), true), // passes within interval - mockTargetEmission('uhc', 'doc3', moment('2020-04-14').valueOf(), true), // passes within interval - mockTargetEmission('uhc', 'doc5', moment('2020-05-05').valueOf(), true), // passes outside interval - ]; - - const refreshRulesEmissions = sinon.stub().resolves({ targetEmissions: emissions }); - const withMockRefresher = wireup.__with__({ refreshRulesEmissions }); - - expect(provider.commitTargetDoc.callCount).to.equal(0); - await withMockRefresher(() => wireup.fetchTasksFor(provider)); - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0][1]).to.equal('2020-04'); - expect(provider.commitTargetDoc.args[0][0]).to.deep.equal([{ id: 'uhc', value: { pass: 2, total: 2 }}]); - - clock.tick(5 * 60 * 60 * 1000); // 6 hours, it's now 2020-05-01 04:00:00 - await withMockRefresher(() => wireup.fetchTasksFor(provider)); - - expect(provider.commitTargetDoc.callCount).to.equal(3); - expect(provider.commitTargetDoc.args[1][1]).to.equal('2020-04'); - expect(provider.commitTargetDoc.args[1][0]).to.deep.equal([{ id: 'uhc', value: { pass: 2, total: 2 }}]); - expect(provider.commitTargetDoc.args[2][1]).to.equal('2020-05'); - expect(provider.commitTargetDoc.args[2][0]).to.deep.equal([{ id: 'uhc', value: { pass: 1, total: 1 }}]); - }); - - it('should update targets when in new interval when refreshing targets', async () => { - const rules = simpleNoolsTemplate(''); - const settings = { - rules, - enableTargets: true, - targets: [{ - id: 'uhc', - }], - monthStartDate: 1, - }; - - clock.setSystemTime(moment('2020-04-30 23:00:00').valueOf()); - await wireup.initialize(provider, settings, {}); - - const emissionsBefore = [ - mockTargetEmission('uhc', 'doc4', moment('2020-02-23').valueOf(), true), // passes outside interval - mockTargetEmission('uhc', 'doc2', moment('2020-03-29').valueOf(), true), // passes outside interval - mockTargetEmission('uhc', 'doc1', moment('2020-04-12').valueOf(), true), // passes within interval - mockTargetEmission('uhc', 'doc3', moment('2020-04-14').valueOf(), true), // passes within interval - mockTargetEmission('uhc', 'doc5', moment('2020-05-05').valueOf(), true), // passes outside interval - ]; - - // simulate that we have a target with date: now (doc3) and that gets counted in both targets - const emissionsAfter = [ - mockTargetEmission('uhc', 'doc4', moment('2020-02-23').valueOf(), true), // passes outside interval - mockTargetEmission('uhc', 'doc2', moment('2020-03-29').valueOf(), true), // passes outside interval - mockTargetEmission('uhc', 'doc1', moment('2020-04-12').valueOf(), true), // passes within interval - mockTargetEmission('uhc', 'doc3', moment('2020-05-05').valueOf(), true), // passes within interval - mockTargetEmission('uhc', 'doc5', moment('2020-05-05').valueOf(), true), // passes outside interval - ]; - - const refreshRulesEmissions = sinon.stub() - .onCall(0).resolves({ targetEmissions: emissionsBefore }) - .onCall(1).resolves({ targetEmissions: emissionsAfter }); - - const withMockRefresher = wireup.__with__({ refreshRulesEmissions }); - // make sure our state has been "calculated" at least once! - await withMockRefresher(() => wireup.fetchTasksFor(provider)); - - expect(provider.commitTargetDoc.callCount).to.equal(1); - expect(provider.commitTargetDoc.args[0][1]).to.equal('2020-04'); - expect(provider.commitTargetDoc.args[0][0]).to.deep.equal([{ id: 'uhc', value: { pass: 2, total: 2 }}]); - - clock.tick(5 * 60 * 60 * 1000); // 6 hours, it's now 2020-05-01 04:00:00 - await withMockRefresher(() => wireup.fetchTargets(provider)); - - expect(provider.commitTargetDoc.callCount).to.equal(3); - expect(provider.commitTargetDoc.args[1][1]).to.equal('2020-04'); - expect(provider.commitTargetDoc.args[1][0]).to.deep.equal([{ id: 'uhc', value: { pass: 2, total: 2 }}]); - expect(provider.commitTargetDoc.args[2][1]).to.equal('2020-05'); - expect(provider.commitTargetDoc.args[2][0]).to.deep.equal([{ id: 'uhc', value: { pass: 2, total: 2 }}]); - }); }); }); diff --git a/tests/e2e/default/admin/admin-access.wdio-spec.js b/tests/e2e/default/admin/admin-access.wdio-spec.js index 4791220fd01..0e5ec16fd14 100644 --- a/tests/e2e/default/admin/admin-access.wdio-spec.js +++ b/tests/e2e/default/admin/admin-access.wdio-spec.js @@ -10,8 +10,7 @@ describe('Acessing the admin app', () => { const parent = placeFactory.place().build({ _id: 'dist1', type: 'district_hospital' }); afterEach(async () => { - await browser.reloadSession(); - await browser.url('/'); + await common.reloadSession(); }); it('should redirect to login when not logged in', async () => { diff --git a/tests/e2e/default/contacts/contact-details.wdio-spec.js b/tests/e2e/default/contacts/contact-details.wdio-spec.js index 3464f964e8e..606b343ed92 100644 --- a/tests/e2e/default/contacts/contact-details.wdio-spec.js +++ b/tests/e2e/default/contacts/contact-details.wdio-spec.js @@ -80,7 +80,6 @@ describe('Contact details page.', () => { { roles: settings.roles, permissions: settings.permissions }, { revert: true, ignoreReload: true } ); - }; before(async () => { @@ -91,7 +90,6 @@ describe('Contact details page.', () => { await utils.createUsers([user]); await loginPage.login(user); - await commonElements.waitForPageLoaded(); }); after(async () => { @@ -124,8 +122,7 @@ describe('Contact details page.', () => { it('should not show reports when permission is disabled', async () => { await updatePermissions(ROLE, [], ['can_view_reports']); - await commonElements.sync(true); - await browser.refresh(); + await commonElements.sync({ expectReload: true, reload: true }); await waitForContactLoaded(true); expect(await (await contactPage.reportsCardSelectors.rhsReportListElement()).isDisplayed()).to.equal(false); @@ -135,8 +132,7 @@ describe('Contact details page.', () => { it('should not show tasks when permission is disabled', async () => { await updatePermissions(ROLE, ['can_view_reports'], ['can_view_tasks']); - await commonElements.sync(true); - await browser.refresh(); + await commonElements.sync({ reload: true, expectReload: true }); await waitForContactLoaded(false); expect(await (await contactPage.reportsCardSelectors.rhsReportListElement()).isDisplayed()).to.equal(true); @@ -160,7 +156,7 @@ describe('Contact details page.', () => { const contactSummaryFile = path.join(__dirname, 'config/contact-summary-error-config.js'); const { contactSummary } = await chtConfUtils.compileNoolsConfig({ contactSummary: contactSummaryFile }); - await utils.updateSettings({ contact_summary: contactSummary }, true); + await utils.updateSettings({ contact_summary: contactSummary }, { ignoreReload: true }); await utils.saveDocs([...places.values(), patient]); }); diff --git a/tests/e2e/default/contacts/edit.wdio-spec.js b/tests/e2e/default/contacts/edit.wdio-spec.js index 2f7dd5972ad..c9c7ff74f06 100644 --- a/tests/e2e/default/contacts/edit.wdio-spec.js +++ b/tests/e2e/default/contacts/edit.wdio-spec.js @@ -1,3 +1,4 @@ +const cloneDeep = require('lodash/cloneDeep'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const commonPage = require('@page-objects/default/common/common.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); @@ -16,7 +17,7 @@ describe('Edit ', () => { const offlineUserContact = personFactory.build({ name: CONTACT_NAME, parent: healthCenter }); const onlineUserContact = personFactory.build({ parent: healthCenter }); - healthCenter.contact = offlineUserContact; + healthCenter.contact = cloneDeep(offlineUserContact); const offlineUser = userFactory.build({ username: 'offline_user', diff --git a/tests/e2e/default/contacts/muting-contact-using-form.wdio-spec.js b/tests/e2e/default/contacts/muting-contact-using-form.wdio-spec.js index a9f14a036d0..3df5b333c81 100644 --- a/tests/e2e/default/contacts/muting-contact-using-form.wdio-spec.js +++ b/tests/e2e/default/contacts/muting-contact-using-form.wdio-spec.js @@ -68,7 +68,7 @@ describe('Mute/Unmute contacts using a specific form - ', () => { it('should show a popup when trying to submit a non-unmuting form against a muted contact', async () => { await utils.revertSettings(true); - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); await commonPage.goToPeople(mutePerson._id); const modalDetails = await contactPage.openFormWithWarning('death_report'); diff --git a/tests/e2e/default/contacts/person-under-area.wdio-spec.js b/tests/e2e/default/contacts/person-under-area.wdio-spec.js index f5989d11e63..d34fc0ae78c 100644 --- a/tests/e2e/default/contacts/person-under-area.wdio-spec.js +++ b/tests/e2e/default/contacts/person-under-area.wdio-spec.js @@ -37,9 +37,7 @@ describe('Create Person Under Area, ', () => { await usersAdminPage.inputAddUserFields(username, 'Jack', 'chw', healthCenter2.name, person2.name, password); await usersAdminPage.saveUser(); - await browser.reloadSession(); - await browser.url('/'); - + await commonPage.reloadSession(); await loginPage.login({ username, password }); await commonPage.goToPeople(); diff --git a/tests/e2e/default/db/initial-replication.wdio-spec.js b/tests/e2e/default/db/initial-replication.wdio-spec.js index 92a907973d7..0b3e7f1d7e8 100644 --- a/tests/e2e/default/db/initial-replication.wdio-spec.js +++ b/tests/e2e/default/db/initial-replication.wdio-spec.js @@ -50,7 +50,7 @@ describe('initial-replication', () => { const localAllDocsPreSync = await chtDbUtils.getDocs(); const docIdsPreSync = dataFactory.ids(localAllDocsPreSync); - await commonPage.sync(false, 7000); + await commonPage.sync(); const localAllDocs = await chtDbUtils.getDocs(); const localDocIds = dataFactory.ids(localAllDocs); @@ -102,7 +102,7 @@ describe('initial-replication', () => { }; before(async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); // we're creating ~2000 docs await utils.saveDocs([...userAllowedDocs.places, ...userDeniedDocs.places]); @@ -115,15 +115,14 @@ describe('initial-replication', () => { after(async () => { await sentinelUtils.skipToSeq(); - await utils.startSentinel(); + await utils.toggleSentinelTransitions(); await utils.deleteUsers([userAllowedDocs.user]); await utils.revertDb([/^form:/], true); await utils.revertSettings(true); }); afterEach(async () => { - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); }); it('should log user in', async () => { diff --git a/tests/e2e/default/db/ongoing-replication.wdio-spec.js b/tests/e2e/default/db/ongoing-replication.wdio-spec.js index d8f4a4685b5..2d032d9807d 100644 --- a/tests/e2e/default/db/ongoing-replication.wdio-spec.js +++ b/tests/e2e/default/db/ongoing-replication.wdio-spec.js @@ -30,9 +30,9 @@ describe('ongoing replication', function() { }); after(async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await sentinelUtils.skipToSeq(); - await utils.startSentinel(); + await utils.toggleSentinelTransitions(); await utils.deleteUsers([userAllowedDocs.user]); await utils.revertDb([/^form:/], true); }); @@ -42,7 +42,7 @@ describe('ongoing replication', function() { const isRevertingSettings = utils.revertSettings(true); if (isRevertingSettings) { await isRevertingSettings; - await commonPage.sync(true, 30000); + await commonPage.sync({ expectReload: true, timeout: 30000 }); } }); @@ -72,7 +72,7 @@ describe('ongoing replication', function() { await saveData(additionalDenied); await browser.throttle('online'); - await commonPage.sync(false, 30000); + await commonPage.sync({ expectReload: true, timeout: 30000 }); const localDocsPostSync = await chtDbUtils.getDocs(); const localDocIds = dataFactory.ids(localDocsPostSync); @@ -132,7 +132,7 @@ describe('ongoing replication', function() { await utils.addTranslations('rnd', {}); await waitForServiceWorker.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); const [rnd] = await chtDbUtils.getDocs(['messages-rnd']); expect(rnd).to.include({ type: 'translations', @@ -144,7 +144,7 @@ describe('ongoing replication', function() { await utils.saveDoc(rnd); await waitForServiceWorker.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); const [updatedRnd] = await chtDbUtils.getDocs(['messages-rnd']); expect(updatedRnd.updated).to.equal(rnd.updated); }); @@ -161,7 +161,7 @@ describe('ongoing replication', function() { await utils.deleteDocs(docIdsToDelete); await waitForServiceWorker.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); const localDocsPostSync = await chtDbUtils.getDocs(); const localDocIds = dataFactory.ids(localDocsPostSync); @@ -171,7 +171,7 @@ describe('ongoing replication', function() { it('should download settings updates', async () => { await commonPage.sync(); await utils.updateSettings({ test: true }, { ignoreReload: 'api' }); - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); const [settings] = await chtDbUtils.getDocs(['settings']); expect(settings.settings.test).to.equal(true); }); diff --git a/tests/e2e/default/enketo/extension-lib-form.wdio-spec.js b/tests/e2e/default/enketo/extension-lib-form.wdio-spec.js index 9d40dbef943..edbe1541daa 100644 --- a/tests/e2e/default/enketo/extension-lib-form.wdio-spec.js +++ b/tests/e2e/default/enketo/extension-lib-form.wdio-spec.js @@ -13,8 +13,7 @@ describe('Extension lib xpath function', () => { const waitForServiceWorker = await utils.waitForApiLogs(utils.SW_SUCCESSFUL_REGEX); await waitForServiceWorker.promise; - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); }); it('calculate average', async () => { diff --git a/tests/e2e/default/enketo/training-cards.wdio-spec.js b/tests/e2e/default/enketo/training-cards.wdio-spec.js index b9bd689ba2f..1a5ff4d781c 100644 --- a/tests/e2e/default/enketo/training-cards.wdio-spec.js +++ b/tests/e2e/default/enketo/training-cards.wdio-spec.js @@ -4,7 +4,6 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const trainingCardsPage = require('@page-objects/default/enketo/training-cards.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); const userFactory = require('@factories/cht/users/users'); -const personFactory = require('@factories/cht/contacts/person'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const privacyPolicyFactory = require('@factories/cht/settings/privacy-policy'); const privacyPage = require('@page-objects/default/privacy-policy/privacy-policy.wdio.page'); @@ -26,7 +25,6 @@ describe('Training Cards', () => { before(async () => { const parent = placeFactory.place().build({ _id: 'dist1', type: 'district_hospital' }); const user = userFactory.build({ roles: [ 'nurse', 'chw' ] }); - const patient = personFactory.build({ parent: { _id: user.place._id, parent: { _id: parent._id } } }); const formDoc = commonPage.createFormDoc(`${__dirname}/forms/training-cards-text-only`); formDoc._id = `form:${formDocId}`; formDoc.internalId = formDocId; @@ -36,8 +34,7 @@ describe('Training Cards', () => { duration: 5, }; - await utils.saveDocs([ parent, patient ]); - await utils.saveDoc(formDoc); + await utils.saveDocs([ parent, formDoc ]); await utils.createUsers([ user ]); await loginPage.login(user); await commonPage.waitForPageLoaded(); diff --git a/tests/e2e/default/enketo/undo-death-report.wdio-spec.js b/tests/e2e/default/enketo/undo-death-report.wdio-spec.js index 05b81c928d1..eb80ba3dca4 100644 --- a/tests/e2e/default/enketo/undo-death-report.wdio-spec.js +++ b/tests/e2e/default/enketo/undo-death-report.wdio-spec.js @@ -26,7 +26,7 @@ describe('Submit an undo death report', () => { await commonPage.goToPeople(person._id); await commonPage.openFastActionReport('death_report'); await deathReportForm.submitDeathReport(); - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); }); it('should submit an undo death report, ' + @@ -43,7 +43,7 @@ describe('Submit an undo death report', () => { ); await genericForm.submitForm(); await commonPage.waitForPageLoaded(); - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); expect(await (await contactPage.deathCardSelectors.deathCard()).isDisplayed()).to.be.false; diff --git a/tests/e2e/default/logging/logging.wdio-spec.js b/tests/e2e/default/logging/logging.wdio-spec.js index b5a309c62c5..4288d53ae9a 100644 --- a/tests/e2e/default/logging/logging.wdio-spec.js +++ b/tests/e2e/default/logging/logging.wdio-spec.js @@ -41,7 +41,6 @@ describe('audit log', () => { continuous: false }; const requestOptions = { - resolveWithFullResponse: true, path: '/_replicator', method: 'POST', body diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index 8e8d81f1205..e8fdbe0bfdc 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -11,8 +11,7 @@ describe('Login page funcionality tests', () => { }; afterEach(async () => { - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); }); describe('Locale', () => { diff --git a/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js b/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js index cf843f1306c..25bf2e5f784 100644 --- a/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js @@ -128,7 +128,7 @@ describe('More Options Menu - Offline User', () => { 'can_export_contacts', 'can_export_messages', 'can_delete_reports', 'can_update_reports']; await utils.updatePermissions(offlineUser.roles, [], allPermissions, true); - await commonPage.sync(true); + await commonPage.sync({ expectReload: true }); }); after(async () => await utils.revertSettings(true)); diff --git a/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js b/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js index 5ce3ef9bdb8..cd31024fa98 100644 --- a/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js @@ -51,9 +51,8 @@ describe('More Options Menu - Offline User - Edit permissions disabled', () => { result = await utils.saveDoc(smsReport); smsReportId = result.id; await utils.createUsers([offlineUser]); + await utils.updatePermissions(offlineUser.roles, [], ['can_edit'], true); await loginPage.login(offlineUser); - await utils.updatePermissions(offlineUser.roles, [], ['can_edit']); - await commonPage.closeReloadModal(); }); after(async () => await utils.revertSettings(true)); diff --git a/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js b/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js index 26e92a11515..d657552dffa 100644 --- a/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js +++ b/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js @@ -22,8 +22,7 @@ describe('Privacy policy', () => { users.forEach((user) => { describe(`for an ${user.isOffline ? 'offline':'online'} user`, () => { before(async () => { - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); await utils.saveDocs([parent, privacyPolicy]); await utils.createUsers([user]); await loginPage.login({ username: user.username, password: user.password, privacyPolicy: true }); @@ -51,16 +50,14 @@ describe('Privacy policy', () => { }); it('should not show on subsequent login', async () => { - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); await loginPage.login({ username: user.username, password: user.password }); await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); expect(await (await privacyPage.privacyWrapper()).isDisplayed()).to.not.be.true; }); it('should show french policy on secondary login', async () => { - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); await loginPage.login({ username: user.username, password: user.password, locale: 'fr', privacyPolicy: true }); await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), frenchTexts); expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; @@ -116,8 +113,7 @@ describe('Privacy policy', () => { before(async () => { await utils.saveDocs([parent, privacyPolicy]); await utils.createUsers([conflictUser]); - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); await loginPage.login({ username: conflictUser.username, password: conflictUser.password, privacyPolicy: true }); }); diff --git a/tests/e2e/default/purge/purge.wdio-spec.js b/tests/e2e/default/purge/purge.wdio-spec.js index fdbde3c4771..b16f0bea3c6 100644 --- a/tests/e2e/default/purge/purge.wdio-spec.js +++ b/tests/e2e/default/purge/purge.wdio-spec.js @@ -42,8 +42,6 @@ describe('purge', function() { const pregnancies = generateReports(125, 'pregnancy'); - const restartSentinel = () => utils.stopSentinel().then(() => utils.startSentinel()); - const getAllReports = () => browser.executeAsync(callback => { window.CHTCore.DB .get() @@ -68,7 +66,7 @@ describe('purge', function() { const runPurging = async () => { const seq = await sentinelUtils.getCurrentSeq(); - await restartSentinel(); + await utils.runSentinelTasks(); await sentinelUtils.waitForPurgeCompletion(seq); await utils.delayPromise(1000); // API has to pick up on purging completing }; @@ -79,8 +77,7 @@ describe('purge', function() { await utils.deleteUsers([user]); await utils.revertDb([/^form:/], true); - await browser.reloadSession(); - await browser.url('/'); + await commonElements.reloadSession(); }); it('purging runs on sync', async () => { @@ -104,7 +101,7 @@ describe('purge', function() { await updatePurgeSettings(filterHomeVisitReports, true); await runPurging(); - await commonElements.sync(true); + await commonElements.sync({ expectReload: true }); allReports = await getAllReports(); // this only works because the client didn't have to "purge" these docs and the revs didn't have diff --git a/tests/e2e/default/pwa/manifest.wdio-spec.js b/tests/e2e/default/pwa/manifest.wdio-spec.js index fc88697b135..a6b1b8d137e 100644 --- a/tests/e2e/default/pwa/manifest.wdio-spec.js +++ b/tests/e2e/default/pwa/manifest.wdio-spec.js @@ -31,7 +31,7 @@ describe('manifest.json', () => { try { await utils.deleteDoc('branding'); } catch (err) { - if (err.statusCode === 404) { + if (err.status === 404) { return; // already not there - success! } throw err; @@ -53,7 +53,7 @@ describe('manifest.json', () => { try { return await utils.getDoc('branding'); } catch (e) { - if (e.statusCode === 404) { + if (e.status === 404) { return { _id: 'branding' }; } throw e; diff --git a/tests/e2e/default/service-worker/service-worker.wdio-spec.js b/tests/e2e/default/service-worker/service-worker.wdio-spec.js index 3fe432351a0..e3d4e41aa53 100644 --- a/tests/e2e/default/service-worker/service-worker.wdio-spec.js +++ b/tests/e2e/default/service-worker/service-worker.wdio-spec.js @@ -67,7 +67,6 @@ describe('Service worker cache', () => { const chw = userFactory.build({ place: district._id }); const login = async () => { - await browser.throttle('online'); await loginPage.login(chw); await commonPage.waitForPageLoaded(); }; @@ -78,6 +77,7 @@ describe('Service worker cache', () => { }; const loginIfNeeded = async () => { + await browser.throttle('online'); if (!await isLoggedIn()) { await login(); } @@ -93,6 +93,10 @@ describe('Service worker cache', () => { await loginIfNeeded(); }); + afterEach(async () => { + await utils.revertSettings(true); + }); + after(async () => { await utils.deleteUsers([chw]); await utils.revertDb([/^form:/], true); @@ -120,6 +124,7 @@ describe('Service worker cache', () => { '/img/icon-pregnant-selected.svg', '/img/icon-pregnant.svg', '/img/icon-filter.svg', + '/img/icon-check.svg', '/img/icon.png', '/img/icon-back.svg', '/img/layers.png', @@ -146,7 +151,7 @@ describe('Service worker cache', () => { await utils.saveDoc(branding); await waitForLogs.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true, serviceWorkerUpdate: true }); await browser.throttle('offline'); // make sure we load the login page from cache await commonPage.logout(); expect(await browser.getTitle()).to.equal('Not Medic'); @@ -160,7 +165,7 @@ describe('Service worker cache', () => { }); await waitForLogs.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true, serviceWorkerUpdate: true }); await browser.throttle('offline'); // make sure we load the login page from cache await commonPage.logout(); @@ -171,7 +176,7 @@ describe('Service worker cache', () => { it('adding new languages triggers login page refresh', async () => { const languageCode = 'ro'; await utils.enableLanguage(languageCode); - await commonPage.sync(true); + await commonPage.sync({ expectReload: true, serviceWorkerUpdate: true }); const waitForLogs = await utils.waitForApiLogs(utils.SW_SUCCESSFUL_REGEX); await utils.addTranslations(languageCode, { @@ -182,7 +187,7 @@ describe('Service worker cache', () => { }); await waitForLogs.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true, serviceWorkerUpdate: true }); await commonPage.logout(); await loginPage.changeLanguage(languageCode, 'Utilizator'); @@ -190,12 +195,10 @@ describe('Service worker cache', () => { expect(await (await loginPage.labelForUser()).getText()).to.equal('Utilizator'); expect(await (await loginPage.loginButton()).getText()).to.equal('Autentificare'); expect(await (await loginPage.labelForPassword()).getText()).to.equal('Parola'); - - await utils.revertSettings(true); }); it('other translation updates do not trigger a login page refresh', async () => { - await commonPage.sync(true); + await commonPage.sync({ expectReload: true, serviceWorkerUpdate: true }); const cacheDetails = await getCachedRequests(true); @@ -206,7 +209,7 @@ describe('Service worker cache', () => { 'some': 'thing', }); await waitForLogs.promise; - await commonPage.sync(true); + await commonPage.sync({ expectReload: true, serviceWorkerUpdate: true }); const updatedCacheDetails = await getCachedRequests(true); diff --git a/tests/e2e/default/sms/rapidpro.wdio-spec.js b/tests/e2e/default/sms/rapidpro.wdio-spec.js index fd5816a026c..9d3c378a324 100644 --- a/tests/e2e/default/sms/rapidpro.wdio-spec.js +++ b/tests/e2e/default/sms/rapidpro.wdio-spec.js @@ -75,7 +75,7 @@ describe('RapidPro SMS Gateway', () => { }); throw new Error('should have thrown'); } catch (err) { - expect(err.responseBody).to.eql({ code: 403, error: 'No incoming key configured' }); + expect(err.body).to.eql({ code: 403, error: 'No incoming key configured' }); } }); @@ -91,7 +91,7 @@ describe('RapidPro SMS Gateway', () => { }); throw new Error('should have thrown'); } catch (err) { - expect(err.responseBody).to.eql({ code: 403, error: 'Missing authorization token' }); + expect(err.body).to.eql({ code: 403, error: 'Missing authorization token' }); } }); @@ -108,7 +108,7 @@ describe('RapidPro SMS Gateway', () => { }); throw new Error('should have thrown'); } catch (err) { - expect(err.responseBody).to.eql({ code: 403, error: 'Incorrect token' }); + expect(err.body).to.eql({ code: 403, error: 'Incorrect token' }); } }); @@ -125,7 +125,7 @@ describe('RapidPro SMS Gateway', () => { }); throw new Error('should have thrown'); } catch (err) { - expect(err.responseBody).to.eql({ code: 403, error: 'Missing authorization token' }); + expect(err.body).to.eql({ code: 403, error: 'Missing authorization token' }); } }); @@ -143,7 +143,7 @@ describe('RapidPro SMS Gateway', () => { }); throw new Error('should have thrown'); } catch (err) { - expect(err.responseBody).to.eql({ code: 400, error: 'Message was not saved' }); + expect(err.body).to.eql({ code: 400, error: 'Message was not saved' }); } }); diff --git a/tests/e2e/default/suites.js b/tests/e2e/default/suites.js index 7514c73f9b1..d0254acd85b 100644 --- a/tests/e2e/default/suites.js +++ b/tests/e2e/default/suites.js @@ -6,6 +6,7 @@ const SUITES = { './more-options-menu/**/*.wdio-spec.js', './users/**/*.wdio-spec.js', './about/**/*.wdio-spec.js', + './training-materials/**/*.wdio-spec.js', './navigation/**/*.wdio-spec.js', './old-navigation/**/*.wdio-spec.js', './privacy-policy/**/*.wdio-spec.js', diff --git a/tests/e2e/default/targets/target-accuracy.wdio-spec.js b/tests/e2e/default/targets/target-accuracy.wdio-spec.js index 171c10b7c86..f647a997fc3 100644 --- a/tests/e2e/default/targets/target-accuracy.wdio-spec.js +++ b/tests/e2e/default/targets/target-accuracy.wdio-spec.js @@ -66,7 +66,7 @@ describe('Target accuracy', () => { await commonPage.goToPeople(chwContact._id); await browser.takeScreenshot(); - await commonPage.sync(false, 2000); + await commonPage.sync(); const serverDoc = await utils.getDoc(targetDocId); expect(serverDoc._rev).to.match(/^1-/); @@ -84,7 +84,7 @@ describe('Target accuracy', () => { await contactsPage.addPerson({ name: 'Jody' }, false); await commonPage.waitForPageLoaded(); - await commonPage.sync(false, 2000); + await commonPage.sync(); const serverDoc = await utils.getDoc(targetDocId); expect(serverDoc._rev).to.match(/^2-/); @@ -102,7 +102,7 @@ describe('Target accuracy', () => { await contactsPage.editPersonName('Jody', 'Jody Ash'); await commonPage.waitForPageLoaded(); - await commonPage.sync(false, 2000); + await commonPage.sync(); const serverDoc = await utils.getDoc(targetDocId); expect(serverDoc._rev).to.match(/^2-/); }); @@ -121,7 +121,7 @@ describe('Target accuracy', () => { await contactsPage.selectLHSRowByText('Jody Ash'); await commonPage.waitForPageLoaded(); - await commonPage.sync(false, 2000); + await commonPage.sync(); const serverDoc = await utils.getDoc(targetDocId); expect(serverDoc._rev).to.match(/^2-/); }); @@ -134,7 +134,7 @@ describe('Target accuracy', () => { await commonPage.waitForPageLoaded(); await browser.pause(1500); // wait for debounced calculation - await commonPage.sync(false, 2000); + await commonPage.sync(); const serverDoc = await utils.getDoc(targetDocId); expect(serverDoc._rev).to.match(/^3-/); @@ -153,7 +153,7 @@ describe('Target accuracy', () => { const contacts = Array.from({ length: 20 }).map(() => personFactory.build(parent)); await utils.saveDocs(contacts); - await commonPage.sync(false, 2000); + await commonPage.sync(); await browser.pause(1500); // wait for debounced calculation const serverDoc = await utils.getDoc(targetDocId); diff --git a/tests/e2e/default/targets/target-aggregates.wdio-spec.js b/tests/e2e/default/targets/target-aggregates.wdio-spec.js index 159f1aea028..54e0ba9c9be 100644 --- a/tests/e2e/default/targets/target-aggregates.wdio-spec.js +++ b/tests/e2e/default/targets/target-aggregates.wdio-spec.js @@ -374,8 +374,7 @@ describe('Target aggregates', () => { targetAggregatesConfig.TARGETS_DEFAULT_CONFIG, userWithManyPlaces ); - await commonPage.sync(true); - await browser.refresh(); + await commonPage.sync({ reload: true, expectReload: true }); await commonPage.goToAnalytics(); await targetAggregatesPage.goToTargetAggregates(true); diff --git a/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js b/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js index 2c6687693e9..d6a4a79f934 100644 --- a/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js +++ b/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js @@ -38,7 +38,7 @@ module.exports = [ ], events: [ { - id: 'person-creation-follow-up', + id: 'person-creation', start: 3, end: 7, dueDate: function (event, contact) { @@ -47,4 +47,35 @@ module.exports = [ } ] }, + + { + name: 'person_create_follow_up', + icon: 'icon-person', + title: 'person_create_follow_up', + appliesTo: 'reports', + appliesToType: ['home_visit'], + appliesIf: function () { + return true; + }, + resolvedIf: function (contact) { + return isFormArraySubmittedInWindow(contact.reports, ['home_visit'], contact.contact.reported_date); + }, + actions: [ + { + type: 'report', + form: 'home_visit' + } + ], + events: [ + { + id: 'person-creation-follow-up', + start: 3, + end: 1, + dueDate: function (event, contact) { + return contact.contact.reported_date; + } + } + ] + }, + ]; diff --git a/tests/e2e/default/tasks/tasks.wdio-spec.js b/tests/e2e/default/tasks/tasks.wdio-spec.js index e2a1ed6ba26..03a972a974e 100644 --- a/tests/e2e/default/tasks/tasks.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks.wdio-spec.js @@ -69,13 +69,6 @@ describe('Tasks', () => { await utils.revertSettings(true); }); - after(async () => { - await utils.deleteUsers([chw]); - await utils.revertDb([/^form:/], true); - await browser.deleteCookies(); - await browser.refresh(); - }); - it('should remove task from list when CHW completes a task successfully', async () => { await tasksPage.compileTasks('tasks-breadcrumbs-config.js', true); @@ -92,6 +85,24 @@ describe('Tasks', () => { expect(list).to.have.length(2); }); + it('should add a task when CHW completes a task successfully, and that task creates another task', async () => { + await tasksPage.compileTasks('tasks-breadcrumbs-config.js', false); + + await commonPage.goToTasks(); + let list = await tasksPage.getTasks(); + expect(list).to.have.length(2); + let task = await tasksPage.getTaskByContactAndForm('Megan Spice', 'person_create'); + await task.click(); + await tasksPage.waitForTaskContentLoaded('Home Visit'); + const taskElement = await tasksPage.getOpenTaskElement(); + await genericForm.submitForm(); + await taskElement.waitForDisplayed(); + await commonPage.sync({ expectReload: true }); + task = await tasksPage.getTaskByContactAndForm('Megan Spice', 'person_create_follow_up'); + list = await tasksPage.getTasks(); + expect(list).to.have.length(3); + }); + it('should load multiple pages of tasks on infinite scrolling', async () => { await tasksPage.compileTasks('tasks-multiple-config.js', true); diff --git a/tests/e2e/default/training-materials/forms/expired-training.xml b/tests/e2e/default/training-materials/forms/expired-training.xml new file mode 100644 index 00000000000..9461ad67964 --- /dev/null +++ b/tests/e2e/default/training-materials/forms/expired-training.xml @@ -0,0 +1,95 @@ + + + + First Training + + + + + **Old feature** + + + **New feature** + + + The "New Action" icon at the bottom of your app has also changed. + + + If you do not understand these changes, please contact your supervisor. + + + When you're ready, go ahead and start using your app. + + + There have been some changes to icons in your app. The next few screens will show you the difference. + + + Read each screen carefully and tap "Next" if you understand. If you need extra support, please contact your supervisor. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/training-materials/forms/first-training.xml b/tests/e2e/default/training-materials/forms/first-training.xml new file mode 100644 index 00000000000..3f50b1d7c07 --- /dev/null +++ b/tests/e2e/default/training-materials/forms/first-training.xml @@ -0,0 +1,95 @@ + + + + First Training + + + + + **Old feature** + + + **New feature** + + + The "New Action" icon at the bottom of your app has also changed. + + + If you do not understand these changes, please contact your supervisor. + + + When you're ready, go ahead and start using your app. + + + There have been some changes to icons in your app. The next few screens will show you the difference. + + + Read each screen carefully and tap "Next" if you understand. If you need extra support, please contact your supervisor. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/training-materials/forms/second-training.xml b/tests/e2e/default/training-materials/forms/second-training.xml new file mode 100644 index 00000000000..9bebba6a873 --- /dev/null +++ b/tests/e2e/default/training-materials/forms/second-training.xml @@ -0,0 +1,95 @@ + + + + Second Training + + + + + **Old icon** + + + **New icon** + + + The "New Action" icon at the bottom of your app has also changed. + + + If you do not understand these changes, please contact your supervisor. + + + When you're ready, go ahead and start using your app. + + + The next few screens will show you the difference. + + + Read each screen carefully and tap "Next" if you understand. If you need extra support, please contact your supervisor. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/training-materials/training-materials.wdio-spec.js b/tests/e2e/default/training-materials/training-materials.wdio-spec.js new file mode 100644 index 00000000000..49231a0a983 --- /dev/null +++ b/tests/e2e/default/training-materials/training-materials.wdio-spec.js @@ -0,0 +1,164 @@ +const fs = require('fs'); +const utils = require('@utils'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const trainingCardsPage = require('@page-objects/default/enketo/training-cards.wdio.page'); +const placeFactory = require('@factories/cht/contacts/place'); +const userFactory = require('@factories/cht/users/users'); +const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); + +describe('Training Materials Page', () => { + const CONFIRM_TITLE = 'Leave training?'; + const CONFIRM_CONTENT = 'This training is not finished. ' + + 'If you leave now, you will lose your progress and be prompted again later to complete it.'; + const FORMS_FOLDER = `${__dirname}/../../../e2e/default/training-materials/forms`; + const FIRST_TRAINING_NAME = 'first_training'; + const FIRST_TRAINING_ID = `training:${FIRST_TRAINING_NAME}`; + const SECOND_TRAINING_NAME = 'second_training'; + const SECOND_TRAINING_ID = `training:${SECOND_TRAINING_NAME}`; + + before(async () => { + const facility = placeFactory.place().build({ _id: 'dist1', type: 'district_hospital' }); + const user = userFactory.build({ roles: [ 'pharmacist', 'chw' ] }); + + const firstXML = fs.readFileSync(`${FORMS_FOLDER}/first-training.xml`, 'utf8'); + const firstTraining = { + _id: `form:${FIRST_TRAINING_ID}`, + internalId: FIRST_TRAINING_ID, + title: FIRST_TRAINING_NAME, + type: 'form', + context: { start_date: new Date().getTime(), user_roles: [ 'pharmacist' ], duration: 5 }, + _attachments: { + xml: { content_type: 'application/octet-stream', data: Buffer.from(firstXML).toString('base64') }, + }, + }; + + const secondXML = fs.readFileSync(`${FORMS_FOLDER}/second-training.xml`, 'utf8'); + const secondTraining = { + _id: `form:${SECOND_TRAINING_ID}`, + internalId: SECOND_TRAINING_ID, + title: SECOND_TRAINING_NAME, + type: 'form', + context: { start_date: new Date().getTime(), user_roles: [ 'pharmacist' ], duration: 5 }, + _attachments: { + xml: { content_type: 'application/octet-stream', data: Buffer.from(secondXML).toString('base64') }, + }, + }; + + const expiredTrainingXML = fs.readFileSync(`${FORMS_FOLDER}/expired-training.xml`, 'utf8'); + const expiredTraining = { + _id: 'form:training:expired_training', + internalId: 'training:expired_training', + title: 'expired_training', + type: 'form', + context: { start_date: '2023-12-8', user_roles: [ 'pharmacist' ], duration: 50 }, + _attachments: { + xml: { content_type: 'application/octet-stream', data: Buffer.from(expiredTrainingXML).toString('base64') }, + }, + }; + + await utils.saveDocs([ facility, firstTraining, expiredTraining, secondTraining ]); + await utils.createUsers([ user ]); + await loginPage.login(user); + }); + + it('should quit training in modal, and be able to complete it later in the Training Material page,' + + ' verify completed trainings display in the list', async () => { + await trainingCardsPage.waitForTrainingCards(); + const trainingModalTitle = await trainingCardsPage.getTrainingTitle(); + expect(trainingModalTitle).to.equal(FIRST_TRAINING_NAME); + + const confirmMessage = await trainingCardsPage.quitTraining(); + expect(confirmMessage.header).to.equal(CONFIRM_TITLE); + expect(confirmMessage.body).to.contain(CONFIRM_CONTENT); + await trainingCardsPage.confirmQuitTraining(); + await trainingCardsPage.checkTrainingCardIsNotDisplayed(); + + await commonPage.openHamburgerMenu(); + await commonPage.openTrainingMaterials(); + + const trainings = await trainingCardsPage.getAllTrainingsText(); + expect(trainings.length).to.equal(2); + expect(trainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.false; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + + await trainingCardsPage.openTrainingMaterial(FIRST_TRAINING_ID); + const trainingMaterialTitle = await trainingCardsPage.getTrainingTitle(); + expect(trainingMaterialTitle).to.equal(FIRST_TRAINING_NAME); + + const introCard = await trainingCardsPage.getCardContent(FIRST_TRAINING_NAME, 'intro/intro_note_1:label"]'); + expect(introCard).to.equal( + 'There have been some changes to icons in your app. The next few screens will show you the difference.' + ); + const nextCard = await trainingCardsPage.getNextCardContent( + FIRST_TRAINING_NAME, + 'action_icons/action_icons_note_1:label"]', + ); + expect(nextCard).to.equal('The "New Action" icon at the bottom of your app has also changed.'); + const lastCard = await trainingCardsPage.getNextCardContent(FIRST_TRAINING_NAME, 'ending/ending_note_1:label"]'); + expect(lastCard).to.equal('If you do not understand these changes, please contact your supervisor.'); + await trainingCardsPage.submitTraining(false); + + const allTrainings = await trainingCardsPage.getAllTrainingsText(); + expect(allTrainings.length).to.equal(2); + expect(allTrainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.true; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + + await commonPage.goToReports(); + const firstReport = await reportsPage.getListReportInfo(await reportsPage.leftPanelSelectors.firstReport()); + expect(firstReport.form).to.equal(FIRST_TRAINING_ID); + }); + + it('should revisit completed trainings and load uncompleted trainings', async () => { + await commonPage.openHamburgerMenu(); + await commonPage.openTrainingMaterials(); + + const trainings = await trainingCardsPage.getAllTrainingsText(); + expect(trainings.length).to.equal(2); + expect(trainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.true; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + + await trainingCardsPage.openTrainingMaterial(FIRST_TRAINING_ID); + const trainingMaterialTitle = await trainingCardsPage.getTrainingTitle(); + expect(trainingMaterialTitle).to.equal(FIRST_TRAINING_NAME); + + const introCard = await trainingCardsPage.getCardContent(FIRST_TRAINING_NAME, 'intro/intro_note_1:label"]'); + expect(introCard).to.equal( + 'There have been some changes to icons in your app. The next few screens will show you the difference.' + ); + const nextCard = await trainingCardsPage.getNextCardContent( + FIRST_TRAINING_NAME, + 'action_icons/action_icons_note_1:label"]', + ); + expect(nextCard).to.equal('The "New Action" icon at the bottom of your app has also changed.'); + + const confirmMessage = await trainingCardsPage.quitTraining(); + expect(confirmMessage.header).to.equal(CONFIRM_TITLE); + expect(confirmMessage.body).to.contain(CONFIRM_CONTENT); + await trainingCardsPage.confirmQuitTraining(); + await trainingCardsPage.checkTrainingCardIsNotDisplayed(); + + await trainingCardsPage.openTrainingMaterial(SECOND_TRAINING_ID); + const secondTrainingTitle = await trainingCardsPage.getTrainingTitle(); + expect(secondTrainingTitle).to.equal(SECOND_TRAINING_NAME); + const secondIntroCard = await trainingCardsPage.getCardContent(SECOND_TRAINING_NAME, 'intro/intro_note_1:label"]'); + expect(secondIntroCard).to.equal( + 'The next few screens will show you the difference.' + ); + + const secondConfirmMessage = await trainingCardsPage.quitTraining(); + expect(secondConfirmMessage.header).to.equal(CONFIRM_TITLE); + expect(secondConfirmMessage.body).to.contain(CONFIRM_CONTENT); + await trainingCardsPage.confirmQuitTraining(); + await trainingCardsPage.checkTrainingCardIsNotDisplayed(); + + const allTrainings = await trainingCardsPage.getAllTrainingsText(); + expect(allTrainings.length).to.equal(2); + expect(allTrainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.true; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + }); +}); diff --git a/tests/e2e/default/transitions/client-side-muting.wdio-spec.js b/tests/e2e/default/transitions/client-side-muting.wdio-spec.js index 18c5304f32d..ea4d896a6ee 100644 --- a/tests/e2e/default/transitions/client-side-muting.wdio-spec.js +++ b/tests/e2e/default/transitions/client-side-muting.wdio-spec.js @@ -141,7 +141,7 @@ describe.skip('Muting', () => { }; const restartSentinel = async (sync = false) => { - await utils.startSentinel(); + await utils.toggleSentinelTransitions(); await sentinelUtils.waitForSentinel(); await browser.refresh(); sync && await commonPage.sync(); @@ -195,7 +195,7 @@ describe.skip('Muting', () => { it('should not process client-side when muting as an online user', async () => { await loginPage.login({username: onlineUser.username, password: onlineUser.password}); - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await utils.updateSettings(settings); await muteClinic(clinic1); @@ -274,7 +274,7 @@ describe.skip('Muting', () => { const settingsWithDisabled = _.cloneDeep(settings); settingsWithDisabled.transitions.muting = { client_side: false }; - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settingsWithDisabled); await muteClinic(clinic2, true); @@ -292,7 +292,7 @@ describe.skip('Muting', () => { // for simplicity, offline means sentinel is stopped it( 'should mute and unmute a person while "offline", with processing in between', async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await mutePerson(patient1, true); @@ -333,7 +333,7 @@ describe.skip('Muting', () => { await commonPage.waitForLoaders(); expectUnmutedNoHistory(await utils.getDoc(clinic1._id)); - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await unmutePerson(patient1, true); @@ -382,7 +382,7 @@ describe.skip('Muting', () => { }); it( 'should mute and unmute a person while "offline", without processing in between', async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await mutePerson(patient1, true); @@ -448,7 +448,7 @@ describe.skip('Muting', () => { }); it( 'should mute and unmute a clinic while "offline", with processing in between', async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await muteClinic(clinic1, true); @@ -520,7 +520,7 @@ describe.skip('Muting', () => { const patient1ServerMutingDate = updatedPatient1.muted; const clinic1ServerMutingDate = updatedClinic1.muted; - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await unmuteClinic(clinic1, true); @@ -594,7 +594,7 @@ describe.skip('Muting', () => { }); it( 'should mute and unmute a clinic while "offline", without processing in between', async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await muteClinic(clinic1, true); @@ -695,7 +695,7 @@ describe.skip('Muting', () => { }); it( 'should mute a clinic and unmute a patient while "offline", without processing in between', async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await muteClinic(clinic1, true); @@ -826,7 +826,7 @@ describe.skip('Muting', () => { it( 'should handle offline multiple muting/unmuting events gracefully', async () => { // this test has value after it ran for at least 100 times - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await commonPage.waitForLoaders(); await muteClinic(clinic1); @@ -885,7 +885,7 @@ describe.skip('Muting', () => { ], }; - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settingsWithValidations); await mutePerson(patient1); @@ -914,7 +914,7 @@ describe.skip('Muting', () => { }); it( 'should work with composite forms', async () => { - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await updateClientSideMutingSettings(settings); await commonPage.goToPeople(healthCenter._id); diff --git a/tests/e2e/default/transitions/create-user-for-contacts.create-user.wdio-spec.js b/tests/e2e/default/transitions/create-user-for-contacts.create-user.wdio-spec.js index 4f3bb54d4e3..066e60e973d 100644 --- a/tests/e2e/default/transitions/create-user-for-contacts.create-user.wdio-spec.js +++ b/tests/e2e/default/transitions/create-user-for-contacts.create-user.wdio-spec.js @@ -78,8 +78,7 @@ describe('Create user when adding contact', () => { NEW_USERS.length = 0; await utils.revertDb([/^form:/], true); await sentinelUtils.waitForSentinel(); - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); }); it('Creates a new user while offline', async () => { @@ -166,7 +165,7 @@ describe('Create user when adding contact', () => { it('creates a new user when Sentinel recovers from outage', async () => { await utils.updateSettings(createSettings(true), {ignoreReload: 'sentinel'}); - await utils.stopSentinel(); + await utils.toggleSentinelTransitions(); await cookieLogin(); await commonPage.goToPeople(district._id); await contactsPage.addPerson({ name: CONTACT_NAME, phone: '+40755696969' }, false); @@ -182,7 +181,7 @@ describe('Create user when adding contact', () => { const userSettings = await utils.getUserSettings({ contactId: chwContactId }); expect(userSettings).to.be.empty; - await utils.startSentinel(); + await utils.toggleSentinelTransitions(); await verifyUserCreation(); }); }); diff --git a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js index 38c4cfd8cf5..a67c72d1c3d 100644 --- a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js +++ b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js @@ -109,7 +109,7 @@ describe('Create user for contacts', () => { path: '/medic/login', body: { user: username, password, locale: 'en' }, method: 'POST', - simple: false, + resolveWithFullResponse: true, noAuth: true, }; return utils.request(opts); @@ -118,7 +118,7 @@ describe('Create user for contacts', () => { const assertUserPasswordChanged = async (user) => { // Cannot login because user's password has been automatically reset const resp0 = await submitLoginRequest(user); - expect(resp0.statusCode).to.equal(401); + expect(resp0.status).to.equal(401); // Update user's password to something we know await utils.request({ @@ -129,7 +129,7 @@ describe('Create user for contacts', () => { // Can login with new password const resp1 = await submitLoginRequest({ ...user, password: DISABLED_USER_PASSWORD }); - expect(resp1.statusCode).to.equal(302); + expect(resp1.status).to.equal(302); }; const assertNewUserSettings = (newUserSettings, newContact, originalUser) => { @@ -169,8 +169,7 @@ describe('Create user for contacts', () => { NEW_USERS.length = 0; await utils.revertDb([/^form:/], true); await sentinelUtils.waitForSentinel(); - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); }); describe('user replace', () => { @@ -758,8 +757,7 @@ describe('Create user for contacts', () => { expect(districtFromRemote.contact._id).to.equal(replacementContactId); // Subsequent form reports are *not* re-parented to the new contact - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); await loginPage.login(ORIGINAL_USER); await commonPage.waitForPageLoaded(); await browser.throttle('offline'); @@ -801,7 +799,7 @@ describe('Create user for contacts', () => { expect(updatedOriginalContact.user_for_contact).to.be.undefined; // Can still login as original user const resp1 = await submitLoginRequest(ONLINE_USER); - expect(resp1.statusCode).to.equal(302); + expect(resp1.status).to.equal(302); // New user not created const newUserSettings = await utils.getUserSettings({ contactId: replacementContactId }); expect(newUserSettings).to.be.empty; diff --git a/tests/e2e/default/translations/enabled-languages.wdio-spec.js b/tests/e2e/default/translations/enabled-languages.wdio-spec.js index f176d1b53f1..c317e5e813f 100644 --- a/tests/e2e/default/translations/enabled-languages.wdio-spec.js +++ b/tests/e2e/default/translations/enabled-languages.wdio-spec.js @@ -1,6 +1,7 @@ const utils = require('@utils'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const adminPage = require('@page-objects/default/admin/admin.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); describe('Enabling/disabling languages', () => { const SETTINGS = { @@ -17,8 +18,7 @@ describe('Enabling/disabling languages', () => { it('should disable a language and enable another', async () => { await utils.updateSettings(SETTINGS, { ignoreReload: true }); - await browser.reloadSession(); - await browser.url('/'); + await commonPage.reloadSession(); // assert English, Spanish, and French are available on the login page let locales = await loginPage.getAllLocales(); diff --git a/tests/e2e/default/translations/incorrect-locale.wdio-spec.js b/tests/e2e/default/translations/incorrect-locale.wdio-spec.js index b930e192255..f1d4faec636 100644 --- a/tests/e2e/default/translations/incorrect-locale.wdio-spec.js +++ b/tests/e2e/default/translations/incorrect-locale.wdio-spec.js @@ -4,7 +4,6 @@ const userSettingsElements = require('@page-objects/default/users/user-settings. const contactElements = require('@page-objects/default/contacts/contacts.wdio.page'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); -const sentinelUtils = require('@utils/sentinel'); const commonPage = require('@page-objects/default/common/common.wdio.page'); describe('Testing Incorrect locale', () => { @@ -31,10 +30,15 @@ describe('Testing Incorrect locale', () => { await utils.enableLanguage(LANGUAGE_CODE); }; - before(async () => { + beforeEach(async () => { await loginPage.cookieLogin(); await utils.saveDoc(place); - await sentinelUtils.waitForSentinel(); + }); + + afterEach(async () => { + await utils.deleteDocs([ place._id, `messages-${LANGUAGE_CODE}` ]); + await utils.revertSettings(true); + await commonElements.reloadSession(); }); it('should work with incorrect locale', async () => { @@ -57,9 +61,5 @@ describe('Testing Incorrect locale', () => { const tasksFilter = await contactElements.getReportTaskFiltersText(); expect(tasksFilter.sort()).to.deep.equal(['1 saptamana', '2 saptamani', 'View all'].sort()); - - await utils.revertSettings(true); - await utils.deleteDocs([ place._id, `messages-${LANGUAGE_CODE}` ]); - await browser.setCookies({ name: 'locale', value: 'en' }); }); }); diff --git a/tests/e2e/upgrade/upgrade.wdio-spec.js b/tests/e2e/upgrade/upgrade.wdio-spec.js index 6891d0204fb..451aab3611a 100644 --- a/tests/e2e/upgrade/upgrade.wdio-spec.js +++ b/tests/e2e/upgrade/upgrade.wdio-spec.js @@ -119,8 +119,7 @@ describe('Performing an upgrade', () => { (testFrontend ? it : xit)('should display current branch in the about page', async () => { await loginPage.login({ username: docs.user.username, password: docs.user.password }); - await commonPage.sync(true); - await browser.refresh(); + await commonPage.sync({ expectReload: true, reload: true }); await commonPage.waitForPageLoaded(); await commonPage.goToAboutPage(); diff --git a/tests/e2e/upgrade/wdio.conf.js b/tests/e2e/upgrade/wdio.conf.js index 2c0b5947dda..8c32ab1e228 100644 --- a/tests/e2e/upgrade/wdio.conf.js +++ b/tests/e2e/upgrade/wdio.conf.js @@ -6,13 +6,13 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const chai = require('chai'); -const { spawn } = require('child_process'); chai.use(require('chai-exclude')); const rpn = require('request-promise-native'); const semver = require('semver'); const utils = require('@utils'); const wdioBaseConfig = require('../../wdio.conf'); +const { generateReport } = require('@utils/allure'); const { MARKET_URL_READ = 'https://staging.dev.medicmobile.org', @@ -75,49 +75,30 @@ const getMainCHTDockerCompose = async () => { }; const TEST_TIMEOUT = 240 * 1000; // 4 minutes +const env = { + ...process.env, + HAPROXY_PORT, + CHT_COMPOSE_PATH: CHT_DOCKER_COMPOSE_FOLDER, + COUCHDB_USER: constants.USERNAME, + COUCHDB_PASSWORD: constants.PASSWORD, + DOCKER_CONFIG_PATH: os.homedir(), + COUCHDB_DATA: CHT_DATA_FOLDER, + CHT_COMPOSE_PROJECT_NAME: CHT_COMPOSE_PROJECT_NAME, + CHT_NETWORK: 'cht-net-upgrade', +}; -const dockerComposeCmd = (...params) => { - const env = { - ...process.env, - HAPROXY_PORT, - CHT_COMPOSE_PATH: CHT_DOCKER_COMPOSE_FOLDER, - COUCHDB_USER: constants.USERNAME, - COUCHDB_PASSWORD: constants.PASSWORD, - DOCKER_CONFIG_PATH: os.homedir(), - COUCHDB_DATA: CHT_DATA_FOLDER, - CHT_COMPOSE_PROJECT_NAME: CHT_COMPOSE_PROJECT_NAME, - CHT_NETWORK: 'cht-net-upgrade', - }; +const upgradeServiceCmd = (command) => { + command = `docker compose -f ${UPGRADE_SERVICE_DC} -p upgrade ${command}`; - params.unshift('-p', 'upgrade'); - - return new Promise((resolve, reject) => { - console.log(...['docker compose', '-f', UPGRADE_SERVICE_DC, ...params]); - const cmd = spawn('docker', ['compose', '-f', UPGRADE_SERVICE_DC, ...params], { env }); - const output = []; - const log = (data, error) => { - data = data.toString(); - output.push(data); - error ? console.error(data) : console.log(data); - }; - - cmd.on('error', (err) => { - console.error(err); - reject(err); - }); - cmd.stdout.on('data', log); - cmd.stderr.on('data', log); - - cmd.on('close', () => resolve(output)); - }); + return utils.runCommand(command, { verbose: true, overrideEnv: env }); }; -const exit = () => dockerComposeCmd('down'); +const exit = () => upgradeServiceCmd('down'); const startUpgradeService = async () => { - await dockerComposeCmd('up', '-d'); + await upgradeServiceCmd('up -d'); let retries = 20; do { - const response = await dockerComposeCmd('ps', '-q'); + const response = await upgradeServiceCmd('ps -q'); if (response.length) { return; } @@ -125,6 +106,16 @@ const startUpgradeService = async () => { } while (--retries); }; +const tearDownServices = async () => { + const composeFilesParam = COMPOSE_FILES + .map(composeFile => '-f ' + path.join(CHT_DOCKER_COMPOSE_FOLDER, `${composeFile}.yml`)) + .join(' '); + const cmd = `docker compose ${composeFilesParam} -p ${CHT_COMPOSE_PROJECT_NAME} down -t 0 --remove-orphans --volumes`; + await utils.runCommand(cmd, { verbose: true, overrideEnv: env }); + + await exit(); +}; + const servicesStartTimeout = () => { return setTimeout(async () => { console.warn('Services took too long to start. Shutting down...'); @@ -133,7 +124,7 @@ const servicesStartTimeout = () => { allotted time. Either run the test multiple times until you load all images, download images manually or increase this timeout. `); - await utils.tearDownServices(); + await tearDownServices(); process.exit(1); }, TEST_TIMEOUT); }; @@ -160,6 +151,12 @@ const upgradeConfig = Object.assign(wdioBaseConfig.config, { await utils.listenForApi(); clearTimeout(tooLongTimeout); }, + + onComplete: async () => { + await tearDownServices(); + await generateReport(); + }, + mochaOpts: { ui: 'bdd', timeout: TEST_TIMEOUT, diff --git a/tests/e2e/visual/contacts/config/contact-summary-extras.js b/tests/e2e/visual/contacts/config/contact-summary-extras.js new file mode 100644 index 00000000000..a2db24950ee --- /dev/null +++ b/tests/e2e/visual/contacts/config/contact-summary-extras.js @@ -0,0 +1,590 @@ +const moment = require('moment'); +const today = moment().startOf('day'); +const now = Date.now(); + +const pregnancyForms = ['pregnancy']; +const antenatalForms = ['pregnancy_home_visit']; +const deliveryForms = ['delivery']; +const pregnancDangerSignForms = [ + 'pregnancy', + 'pregnancy_home_visit', + 'pregnancy_danger_sign', + 'pregnancy_danger_sign_follow_up' +]; +const MAX_DAYS_IN_PREGNANCY = 42 * 7; +const AVG_DAYS_IN_PREGNANCY = 280; +const IMMUNIZATION_DOSES = [ + ['hep_a_1', 'HA1'], + ['hep_a_2', 'HA2'], + ['flu', 'FLU'], + ['polio_0', 'OPV0'], + ['polio_1', 'OPV1'], + ['polio_2', 'OPV2'], + ['polio_3', 'OPV3'], + ['penta_1', 'PENTA1'], + ['penta_2', 'PENTA2'], + ['penta_3', 'PENTA3'], + ['rotavirus_1', 'ROTA1'], + ['rotavirus_2', 'ROTA2'], + ['rotavirus_3', 'ROTA3'] +]; +const IMMUNIZATION_LIST = [ + 'hep_a', + 'flu', + 'polio', + 'penta', + 'rotavirus' +]; +const immunizationForms = [ + 'PENTA1', + 'PENTA2', + 'PENTA3', + 'OPV0', + 'OPV1', + 'OPV2', + 'OPV3', + 'ROTA1', + 'ROTA2', + 'ROTA3', + 'FLU', + 'HA1', + 'HA2' +]; + +const isReportValid = (report) => { + return ( + report.form && + report.fields && + report.reported_date + ); +}; + +const getField = (report, fieldPath) => ['fields', ...(fieldPath || '').split('.')] + .reduce((prev, fieldName) => { + if (prev === undefined) { + return undefined; + } + return prev[fieldName]; + }, report); + +const getFormArraySubmittedInWindow = (allReports, formArray, start, end) => allReports.filter( + (report) => formArray.includes(report.form) && + report.reported_date >= start && + report.reported_date <= end +); + +const getNewestReport = (allReports, forms) => { + const result = allReports + .filter((report) => isReportValid(report) && forms.includes(report.form)) + .reduce((newest, report) => { + return !newest || report.reported_date > newest.reported_date ? report : newest; + }, null); + + return result; +}; + +const getLMPDateFromPregnancy = (report) => { + const lmpDate = getField(report, 'lmp_date_8601'); + return isPregnancyForm(report) && lmpDate && moment(lmpDate); +}; + +const getLMPDateFromPregnancyFollowUp = (report) => { + const lmpDate = getField(report, 'lmp_date_8601'); + return isPregnancyFollowUpForm(report) && lmpDate && moment(lmpDate); +}; + +const getMostRecentLMPDateForPregnancy = (allReports, pregnancyReport) => { + let mostRecentLMP = getLMPDateFromPregnancy(pregnancyReport); + let mostRecentReportDate = pregnancyReport.reported_date; + + getSubsequentPregnancyFollowUps(allReports, pregnancyReport).forEach((visit) => { + const lmpFromPregnancyFollowUp = getLMPDateFromPregnancyFollowUp(visit); + const lmpUpdated = getField(visit, 'lmp_updated') === 'yes'; + + if (visit.reported_date > mostRecentReportDate && lmpUpdated) { + mostRecentReportDate = visit.reported_date; + mostRecentLMP = lmpFromPregnancyFollowUp; + } + }); + + return mostRecentLMP; +}; + +const getMostRecentEDDForPregnancy = (allReports, report) => { + const lmpDate = getMostRecentLMPDateForPregnancy(allReports, report); + + if (!lmpDate) { + return; + } + + return lmpDate.clone().add(AVG_DAYS_IN_PREGNANCY, 'days'); +}; + +const getDeliveryDate = (report) => { + const deliveryDate = getField(report, 'delivery_outcome.delivery_date'); + + return isDeliveryForm(report) && deliveryDate && moment(deliveryDate); +}; + +const getNextANCVisitDate = (allReports, report) => { + let nextVisit = getField(report, 't_pregnancy_follow_up_date'); + let eddReportDate = report.reported_date; + + const followUps = getSubsequentPregnancyFollowUps(allReports, report); + + followUps.forEach((followUpReport) => { + const followUpDate = getField(followUpReport, 't_pregnancy_follow_up_date'); + + if (followUpReport.reported_date > eddReportDate && followUpDate) { + eddReportDate = followUpReport.reported_date; + nextVisit = followUpDate; + } + }); + + return moment(nextVisit); +}; + +const getDangerSignCodes = (report) => { + const dangerSignCodes = []; + + if (getField(report, 't_danger_signs_referral_follow_up') === 'yes') { + const dangerSignsObj = getField(report, 'danger_signs'); + + if (dangerSignsObj) { + Object.keys(dangerSignsObj).forEach((dangerSign) => { + if (dangerSignsObj[dangerSign] === 'yes' && dangerSign !== 'r_danger_sign_present') { + dangerSignCodes.push(dangerSign); + } + }); + } + } + + return dangerSignCodes; +}; + +const getLatestDangerSignsForPregnancy = (allReports, pregnancy) => { + if (!pregnancy) { + return []; + } + + let lmpDate = getMostRecentLMPDateForPregnancy(allReports, pregnancy); + if (!lmpDate) { + lmpDate = moment(pregnancy.reported_date); + } + + const allReportsWithDangerSigns = getFormArraySubmittedInWindow( + allReports, + pregnancDangerSignForms, + lmpDate.toDate(), + lmpDate.clone().add(MAX_DAYS_IN_PREGNANCY, 'days').toDate() + ); + + const allRelevantReports = []; + allReportsWithDangerSigns.forEach((report) => { + if (isPregnancyFollowUpForm(report)) { + if (getField(report, 'pregnancy_summary.visit_option') === 'yes') { + allRelevantReports.push(report); + } + } else { + allRelevantReports.push(report); + } + }); + + const recentReport = getNewestReport(allRelevantReports, pregnancDangerSignForms); + if (!recentReport) { + return []; + } + + return getDangerSignCodes(recentReport); +}; + + +const getRiskFactorsFromPregnancy = (report) => { + const riskFactors = []; + + if (!isPregnancyForm(report)) { + return []; + } + + if (getField(report, 'risk_factors.r_risk_factor_present') === 'yes') { + if (getField(report, 'risk_factors.risk_factors_history.first_pregnancy') === 'yes') { + riskFactors.push('first_pregnancy'); + } + + if (getField(report, 'risk_factors.risk_factors_history.previous_miscarriage') === 'yes') { + riskFactors.push('previous_miscarriage'); + } + + const riskFactorsPrimary = getField(report, 'risk_factors.risk_factors_present.primary_condition'); + const riskFactorsSecondary = getField(report, 'risk_factors.risk_factors_present.secondary_condition'); + + if (riskFactorsPrimary) { + riskFactors.push(...riskFactorsPrimary.split(' ')); + } + + if (riskFactorsSecondary) { + riskFactors.push(...riskFactorsSecondary.split(' ')); + } + } + + return riskFactors; +}; + +const getNewRiskFactorsFromFollowUps = (report) => { + const riskFactors = []; + + if (!isPregnancyFollowUpForm(report)) { + return []; + } + + if (getField(report, 'anc_visits_hf.risk_factors.r_risk_factor_present') === 'yes') { + const newRiskFactors = getField(report, 'anc_visits_hf.risk_factors.new_risks'); + + if (newRiskFactors) { + riskFactors.push(...newRiskFactors.split(' ')); + } + } + + return riskFactors; +}; + +const getAllRiskFactors = (allReports, pregnancy) => { + const riskFactorCodes = getRiskFactorsFromPregnancy(pregnancy); + const pregnancyFollowUps = getSubsequentPregnancyFollowUps(allReports, pregnancy); + + pregnancyFollowUps.forEach((visit) => { + riskFactorCodes.push(...getNewRiskFactorsFromFollowUps(visit)); + }); + + return riskFactorCodes; +}; + +const getRiskFactorExtra = (report) => { + if (!report){ + return; + } + + if (isPregnancyForm(report)) { + return getField(report, 'risk_factors.risk_factors_present.additional_risk'); + } + + if (isPregnancyFollowUpForm(report)) { + return getField(report, 'anc_visits_hf.risk_factors.additional_risk'); + } +}; + +const getAllRiskFactorExtra = (allReports, pregnancy) => { + const riskFactorsExtra = []; + + const riskFactorExtraFromPregnancy = getRiskFactorExtra(pregnancy); + if (riskFactorExtraFromPregnancy) { + riskFactorsExtra.push(riskFactorExtraFromPregnancy); + } + + const pregnancyFollowUps = getSubsequentPregnancyFollowUps(allReports, pregnancy); + pregnancyFollowUps.forEach((visit) => { + const riskFactorExtraFromVisit = getRiskFactorExtra(visit); + if (riskFactorExtraFromVisit) { + riskFactorsExtra.push(riskFactorExtraFromVisit); + } + }); + + return riskFactorsExtra; +}; + +const isHighRiskPregnancy = (allReports, pregnancy) => { + const riskFactors = getAllRiskFactors(allReports, pregnancy); + const riskFactorExtra = getAllRiskFactorExtra(allReports, pregnancy); + const dangerSigns = getDangerSignCodes(pregnancy); + + return riskFactors.length > 0 || riskFactorExtra.length > 0 || dangerSigns.length > 0; +}; + +const isAlive = (thisContact) => thisContact && !thisContact.date_of_death; + +const isPregnancyForm = (report) => report && pregnancyForms.includes(report.form); + +const isPregnancyFollowUpForm = (report) => report && antenatalForms.includes(report.form); + +const isDeliveryForm = (report) => report && deliveryForms.includes(report.form); + +const getSubsequentPregnancies = (allReports, refReport) => allReports.filter( + (report) => isPregnancyForm(report) && + report.reported_date > refReport.reported_date +); + +const isActivePregnancy = (thisContact, allReports, report) => { + if ( + thisContact.type !== 'person' || + !isAlive(thisContact) || + !isPregnancyForm(report) + ) { + return false; + } + + const lmpDate = + getMostRecentLMPDateForPregnancy(allReports, report) || + report.reported_date; + + const isPregnancyRegisteredWithin9Months = + lmpDate > today.clone().subtract(MAX_DAYS_IN_PREGNANCY, 'days'); + + const isPregnancyTerminatedByDeliveryInLast6Weeks = + getSubsequentDeliveries(allReports, report, 6 * 7).length > 0; + + const isPregnancyTerminatedByAnotherPregnancyReport = + getSubsequentPregnancies(allReports, report).length > 0; + + return ( + isPregnancyRegisteredWithin9Months && + !isPregnancyTerminatedByDeliveryInLast6Weeks && + !isPregnancyTerminatedByAnotherPregnancyReport && + !getRecentANCVisitWithEvent(allReports, report, 'abortion') && + !getRecentANCVisitWithEvent(allReports, report, 'miscarriage') + ); +}; + +const isPregnant = (allReports) => allReports.some((report) => isActivePregnancy(report)); + +const isReadyForNewPregnancy = (thisContact, allReports) => { + if (thisContact.type !== 'person') { + return false; + } + + const mostRecentPregnancyReport = getNewestReport(allReports, pregnancyForms); + const mostRecentDeliveryReport = getNewestReport(allReports, deliveryForms); + + if (!mostRecentPregnancyReport && !mostRecentDeliveryReport) { + // No previous pregnancy or delivery recorded, fresh profile + return true; + } + + if (!mostRecentPregnancyReport) { + // Delivery report without pregnancy report + const deliveryDate = getDeliveryDate(mostRecentDeliveryReport); + return deliveryDate && deliveryDate < today.clone().subtract(6 * 7, 'day'); + } + + const isPregnancyNewer = + !mostRecentDeliveryReport || + mostRecentDeliveryReport.reported_date < mostRecentPregnancyReport.reported_date; + + if (isPregnancyNewer) { + // Pregnancy report newer than delivery or no delivery report + const lmpDate = + getMostRecentLMPDateForPregnancy(allReports, mostRecentPregnancyReport) || + moment(mostRecentPregnancyReport.reported_date); + + if (lmpDate < today.clone().subtract(MAX_DAYS_IN_PREGNANCY, 'day')) { + return true; + } + + return ( + getRecentANCVisitWithEvent(allReports, mostRecentPregnancyReport, 'abortion') || + getRecentANCVisitWithEvent(allReports, mostRecentPregnancyReport, 'miscarriage') + ); + } + + // Both pregnancy and delivery report; delivery report is newer + return ( + getRecentANCVisitWithEvent(allReports, mostRecentPregnancyReport, 'abortion') || + getRecentANCVisitWithEvent(allReports, mostRecentPregnancyReport, 'miscarriage') + ); +}; + +const isReadyForDelivery = (thisContact, allReports) => { + if (thisContact.type !== 'person') { + return false; + } + + const latestPregnancy = getNewestReport(allReports, pregnancyForms); + const latestDelivery = getNewestReport(allReports, deliveryForms); + + if (!latestPregnancy && !latestDelivery) { + // No previous pregnancy or delivery + return true; + } + + if ( + latestDelivery && + (!latestPregnancy || latestDelivery.reported_date > latestPregnancy.reported_date) + ) { + // No pregnancy registration; check if previous delivery was at least 7 months ago + return getDeliveryDate(latestDelivery) < today.clone().subtract(7, 'months'); + } + + if (latestPregnancy) { + const lmpDate = getMostRecentLMPDateForPregnancy(allReports, latestPregnancy); + if (!lmpDate) { + // No LMP; show readiness until 280 days + 6 weeks from the registration date + return moment(latestPregnancy.reported_date) + .clone() + .startOf('day') + .add(280 + 6 * 7, 'days') + .isSameOrBefore(today); + } + + const edd = getMostRecentEDDForPregnancy(allReports, latestPregnancy); + // Pregnancy registration with LMP; check readiness between 6 months after LMP and EDD + 6 weeks + return today.isBetween( + lmpDate.clone().add(6, 'months'), + edd.clone().add(6, 'weeks') + ); + } + + return false; +}; + +const getRecentANCVisitWithEvent = (allReports, pregnancyReport, event) => { + const followUps = getSubsequentPregnancyFollowUps(allReports, pregnancyReport); + const latestFollowUp = getNewestReport(followUps, antenatalForms); + + return latestFollowUp && + getField(latestFollowUp, 'pregnancy_summary.visit_option') === event + ? latestFollowUp + : undefined; +}; + +const getSubsequentDeliveries = (allReports, pregnancyReport, withinLastXDays) => allReports.filter((report) => { + const isAfterPregnancy = report.reported_date > pregnancyReport.reported_date; + const isWithinDays = !withinLastXDays || report.reported_date >= today.clone().subtract(withinLastXDays, 'days'); + return isDeliveryForm(report) && isAfterPregnancy && isWithinDays; +}); + +const getSubsequentPregnancyFollowUps = (allReports, pregnancyReport) => { + const lmpDate = getLMPDateFromPregnancy(pregnancyReport) || moment(pregnancyReport.reported_date); + + return allReports.filter(visitReport => isPregnancyFollowUpForm(visitReport) && + visitReport.reported_date > pregnancyReport.reported_date && + moment(visitReport.reported_date).isBefore(lmpDate.clone().add(MAX_DAYS_IN_PREGNANCY, 'days'))); +}; + +const countANCFacilityVisits = (allReports, pregnancyReport) => { + let ancHFVisits = 0; + + const initialVisits = getField(pregnancyReport, 'anc_visits_hf.anc_visits_hf_past.visited_hf_count'); + if (initialVisits && !isNaN(initialVisits)) { + ancHFVisits += parseInt(initialVisits, 10); + } + + const pregnancyFollowUps = getSubsequentPregnancyFollowUps(allReports, pregnancyReport); + ancHFVisits += pregnancyFollowUps.reduce((sum, report) => { + const pastANCHFVisits = getField(report, 'anc_visits_hf.anc_visits_hf_past'); + + if (!pastANCHFVisits) { + return sum; + } + + if (pastANCHFVisits.last_visit_attended === 'yes') { + sum += 1; + } + + const otherVisitCount = pastANCHFVisits.visited_hf_count; + if (pastANCHFVisits.report_other_visits === 'yes' && !isNaN(otherVisitCount)) { + sum += parseInt(otherVisitCount, 10); + } + + return sum; + }, 0); + + return ancHFVisits; +}; + +const knowsHIVStatusInPast3Months = (allReports) => { + const pregnancyFormsIn3Months = getFormArraySubmittedInWindow( + allReports, + pregnancyForms, + today.clone().subtract(3, 'months'), + today + ); + + return pregnancyFormsIn3Months.some( + (report) => getField(report, 'pregnancy_new_or_current.hiv_status.hiv_status_know') === 'yes' + ); +}; + +const addImmunizations = (master, vaccinesReceived) => { + IMMUNIZATION_DOSES.forEach((dose) => { + if (!master[dose[0]]) { + master[dose[0]] = typeof vaccinesReceived === 'string' + ? vaccinesReceived.toUpperCase() === dose[1] + : vaccinesReceived[`received_${dose[0]}`] === 'yes'; + } + }); +}; + +const getAgeInMonths = () => { + // eslint-disable-next-line no-undef + if (contact.date_of_birth && contact.date_of_birth !== '') { + // eslint-disable-next-line no-undef + const birthDate = new Date(contact.date_of_birth); + const ageInMs = new Date(Date.now() - birthDate.getTime()); + return (Math.abs(ageInMs.getFullYear() - 1970) * 12) + ageInMs.getMonth(); + } + return null; +}; + +const initImmunizations = () => { + const master = {}; + IMMUNIZATION_DOSES.forEach((dose) => { + master[dose[0]] = false; + }); + return master; +}; + +const isSingleDose = (name) => IMMUNIZATION_DOSES.some((d) => d[0] === name); + +const count = (arr, fn) => arr.filter(fn).length; + +const countDosesReceived = (master, name) => { + return count(IMMUNIZATION_DOSES, (dose) => { + return master[dose[0]] && dose[0].startsWith(name + '_'); + }); +}; + +const countDosesPossible = name => count(IMMUNIZATION_DOSES, dose => dose[0].startsWith(name + '_')); + +const countReportsSubmittedInWindow = (form, end) => { + // eslint-disable-next-line no-undef + return count(reports, (r) => { + return r.reported_date <= end && form.includes(r.form); + }); +}; + +module.exports = { + today, + MAX_DAYS_IN_PREGNANCY, + IMMUNIZATION_LIST, + isHighRiskPregnancy, + getNewestReport, + getSubsequentPregnancyFollowUps, + getSubsequentDeliveries, + isAlive, + isPregnant, + isActivePregnancy, + countANCFacilityVisits, + knowsHIVStatusInPast3Months, + getAllRiskFactors, + getAllRiskFactorExtra, + getDangerSignCodes, + getLatestDangerSignsForPregnancy, + getNextANCVisitDate, + isReadyForNewPregnancy, + isReadyForDelivery, + getMostRecentLMPDateForPregnancy, + getMostRecentEDDForPregnancy, + getDeliveryDate, + getFormArraySubmittedInWindow, + getRecentANCVisitWithEvent, + getField, + getRiskFactorsFromPregnancy, + now, + addImmunizations, + getAgeInMonths, + initImmunizations, + isSingleDose, + countDosesReceived, + countDosesPossible, + countReportsSubmittedInWindow, + immunizationForms, +}; diff --git a/tests/e2e/visual/contacts/config/contact-summary.templated.js b/tests/e2e/visual/contacts/config/contact-summary.templated.js new file mode 100644 index 00000000000..c705a85d25a --- /dev/null +++ b/tests/e2e/visual/contacts/config/contact-summary.templated.js @@ -0,0 +1,323 @@ +const moment = require('moment'); +const extras = require('./contact-summary-extras'); + +const { + today, + isHighRiskPregnancy, + getNewestReport, + getSubsequentPregnancyFollowUps, + isAlive, + isReadyForNewPregnancy, + isReadyForDelivery, + isActivePregnancy, + countANCFacilityVisits, + getAllRiskFactors, + getLatestDangerSignsForPregnancy, + getNextANCVisitDate, + getMostRecentLMPDateForPregnancy, + getMostRecentEDDForPregnancy, + getRecentANCVisitWithEvent, + getAllRiskFactorExtra, + getField, + now, + IMMUNIZATION_LIST, + addImmunizations, + getAgeInMonths, + initImmunizations, + isSingleDose, + countDosesReceived, + countDosesPossible, + countReportsSubmittedInWindow, + immunizationForms, +} = extras; + +// contact, reports, lineage are globally available for contact-summary +// eslint-disable-next-line no-undef +const thisContact = contact; +// eslint-disable-next-line no-undef +const thisLineage = lineage; +// eslint-disable-next-line no-undef +const allReports = reports; + +const context = { + alive: isAlive(thisContact), + muted: false, + showPregnancyForm: isReadyForNewPregnancy(thisContact, allReports), + showDeliveryForm: isReadyForDelivery(thisContact, allReports), +}; + +const fields = [ + { + appliesToType: 'person', + label: 'patient_id', + value: thisContact.patient_id, + width: 4, + }, + { + appliesToType: 'person', + label: 'contact.age', + value: thisContact.date_of_birth, + width: 4, + filter: 'age', + }, + { + appliesToType: 'person', + label: 'contact.sex', + value: `contact.sex.${thisContact.sex}`, + translate: true, + width: 4, + }, + { + appliesToType: 'person', + label: 'person.field.phone', + value: thisContact.phone, + width: 4, + }, + { + appliesToType: 'person', + label: 'contact.parent', + value: thisLineage, + filter: 'lineage', + }, + { + appliesToType: '!person', + label: 'contact', + value: thisContact.contact && thisContact.contact.name, + width: 4, + }, + { + appliesToType: '!person', + label: 'contact.phone', + value: thisContact.contact && thisContact.contact.phone, + width: 4, + }, + { + appliesToType: 'clinic', + label: 'Last Visited', + value: '36 days ago', + width: 4, + }, + { + appliesToType: '!person', + appliesIf: function () { + return thisContact.parent && thisLineage[0]; + }, + label: 'contact.parent', + value: thisLineage, + filter: 'lineage', + }, +]; + +if (thisContact.short_name) { + fields.unshift({ + appliesToType: 'person', + label: 'contact.short_name', + value: thisContact.short_name, + width: 4, + }); +} + +const cards = [ + { + label: 'contact.profile.pregnancy.active', + appliesToType: 'report', + appliesIf: function (report) { + return isActivePregnancy(thisContact, allReports, report); + }, + fields: function (report) { + const fields = []; + const riskFactors = getAllRiskFactors(allReports, report); + const riskFactorsCustom = getAllRiskFactorExtra(allReports, report); + const dangerSigns = getLatestDangerSignsForPregnancy(allReports, report); + const highRisk = isHighRiskPregnancy(allReports, report); + const mostRecentANC = getNewestReport(allReports, ['pregnancy', 'pregnancy_home_visit']); + const mostRecentANCDate = moment(mostRecentANC.reported_date); + const lmp_date = getMostRecentLMPDateForPregnancy(allReports, report); + const edd_ms = getMostRecentEDDForPregnancy(allReports, report); + const nextAncVisitDate = getNextANCVisitDate(allReports, report); + const weeksPregnant = lmp_date ? today.diff(lmp_date, 'weeks') : null; + + let lmp_approx = getField(report, 'lmp_approx'); + let reportDate = report.reported_date; + + getSubsequentPregnancyFollowUps(allReports, report).forEach(function (followUpReport) { + if (followUpReport.reported_date > reportDate && getField(followUpReport, 'lmp_updated') === 'yes') { + reportDate = followUpReport.reported_date; + if (getField(followUpReport, 'lmp_method_approx')) { + lmp_approx = getField(followUpReport, 'lmp_method_approx'); + } + } + }); + + const migratedReport = getRecentANCVisitWithEvent(allReports, report, 'migrated'); + const refusedReport = getRecentANCVisitWithEvent(allReports, report, 'refused'); + const stopReport = migratedReport || refusedReport; + + if (stopReport) { + const clearAll = getField(stopReport, 'pregnancy_ended.clear_option') === 'clear_all'; + fields.push( + { + label: 'contact.profile.change_care', + value: migratedReport ? 'Migrated out of area' : 'Refusing care', + width: 6 + }, + { label: 'contact.profile.tasks_on_off', value: clearAll ? 'Off' : 'On', width: 6 } + ); + } + + fields.push( + { + label: 'Weeks Pregnant', + value: weeksPregnant !== null + ? { number: weeksPregnant, approximate: lmp_approx === 'yes' } + : 'contact.profile.value.unknown', + translate: weeksPregnant === null, + filter: weeksPregnant !== null ? 'weeksPregnant' : '', + width: 6 + }, + { + label: 'contact.profile.edd', + value: edd_ms ? edd_ms.valueOf() : 'contact.profile.value.unknown', + translate: !edd_ms, + filter: edd_ms ? 'simpleDate' : '', + width: 6 + } + ); + + if (highRisk) { + let riskValue = ''; + if (!riskFactors && riskFactorsCustom) { + riskValue = riskFactorsCustom.join(', '); + } else if (riskFactors.length > 1 || (riskFactors && riskFactorsCustom)) { + riskValue = 'contact.profile.risk.multiple'; + } else { + riskValue = 'contact.profile.danger_sign.' + riskFactors[0]; + } + fields.push( + { label: 'contact.profile.risk.high', value: riskValue, translate: true, icon: 'icon-risk', width: 6 } + ); + } + + if (dangerSigns.length > 0) { + fields.push({ + label: 'contact.profile.danger_signs.current', + value: dangerSigns.length > 1 + ? 'contact.profile.danger_sign.multiple' + : `contact.profile.danger_sign.${dangerSigns[0]}`, + translate: true, + width: 6 + }); + } + + fields.push( + { + label: 'contact.profile.visit', + value: 'contact.profile.visits.of', + context: { count: countANCFacilityVisits(allReports, report), total: 8 }, + translate: true, + width: 6 + }, + { label: 'contact.profile.last_visited', value: mostRecentANCDate.valueOf(), filter: 'relativeDay', width: 6 } + ); + + if (nextAncVisitDate && nextAncVisitDate.isSameOrAfter(today)) { + fields.push( + { label: 'contact.profile.anc.next', value: nextAncVisitDate.valueOf(), filter: 'simpleDate', width: 6 } + ); + } + + return fields; + }, + modifyContext: function (ctx, report) { + let lmpDate = getField(report, 'lmp_date_8601'); + let lmpMethodApprox = getField(report, 'lmp_method_approx'); + let hivTested = getField(report, 'hiv_status_known'); + let dewormingMedicationReceived = getField(report, 'deworming_med_received'); + let ttReceived = getField(report, 'tt_received'); + const riskFactorCodes = getAllRiskFactors(allReports, report); + const riskFactorsCustom = getAllRiskFactorExtra(allReports, report); + let pregnancyFollowupDateRecent = getField(report, 't_pregnancy_follow_up_date'); + + getSubsequentPregnancyFollowUps(allReports, report).forEach(function (followUpReport) { + if (getField(followUpReport, 'lmp_updated') === 'yes') { + lmpDate = getField(followUpReport, 'lmp_date_8601'); + lmpMethodApprox = getField(followUpReport, 'lmp_method_approx'); + } + hivTested = getField(followUpReport, 'hiv_status_known'); + dewormingMedicationReceived = getField(followUpReport, 'deworming_med_received'); + ttReceived = getField(followUpReport, 'tt_received'); + if (getField(followUpReport, 't_pregnancy_follow_up') === 'yes') { + pregnancyFollowupDateRecent = getField(followUpReport, 't_pregnancy_follow_up_date'); + } + }); + + ctx.lmp_date_8601 = lmpDate; + ctx.lmp_method_approx = lmpMethodApprox; + ctx.is_active_pregnancy = true; + ctx.deworming_med_received = dewormingMedicationReceived; + ctx.hiv_tested_past = hivTested; + ctx.tt_received_past = ttReceived; + ctx.risk_factor_codes = riskFactorCodes.join(' '); + ctx.risk_factor_extra = riskFactorsCustom.join('; '); + ctx.pregnancy_follow_up_date_recent = pregnancyFollowupDateRecent; + ctx.pregnancy_uuid = report._id; + } + }, + { + label: 'contact.profile.immunizations', + appliesToType: 'person', + appliesIf: function() { + return getAgeInMonths() < 144; + }, + fields: function() { + const immunizations = initImmunizations(); + // eslint-disable-next-line no-undef + reports.forEach(function(report) { + if (report.form === 'immunization_visit') { + if (report.fields && report.fields.vaccines_received) { + addImmunizations(immunizations, report.fields.vaccines_received); + } + } else if (report.form === 'C_IMM') { + addImmunizations(immunizations, report.fields); + } else { + addImmunizations(immunizations, report.form); + } + }); + + const fields = IMMUNIZATION_LIST.map(function(imm) { + const field = { + label: 'contact.profile.imm.' + imm, + translate: true, + width: 6, + }; + if (isSingleDose(imm)) { + field.value = immunizations[imm] ? 'yes' : 'no'; + } else { + field.value = 'contact.profile.imm.doses'; + field.context = { + count: countDosesReceived(immunizations, imm), + total: countDosesPossible(imm), + }; + } + return field; + }); + + if (!fields.length) { + fields.push({ + label: 'contact.profile.imm.generic', + translate: true, + value: countReportsSubmittedInWindow(immunizationForms, now), + width: 12, + }); + } + + return fields; + }, + }, +]; + +module.exports = { + context: context, + cards: cards, + fields: fields +}; diff --git a/tests/e2e/visual/contacts/contact-user-hierarchy-creation.wdio-spec.js b/tests/e2e/visual/contacts/contact-user-hierarchy-creation.wdio-spec.js new file mode 100644 index 00000000000..0ba1c7dd251 --- /dev/null +++ b/tests/e2e/visual/contacts/contact-user-hierarchy-creation.wdio-spec.js @@ -0,0 +1,117 @@ + +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); +const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); +const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page'); +const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page'); +const searchPage = require('@page-objects/default/search/search.wdio.page'); +const usersAdminPage = require('@page-objects/default/users/user.wdio.page'); + +const utils = require('@utils'); +const { generateScreenshot } = require('@utils/screenshots'); + +describe('Creating and editing contacts and users', () => { + const healthFacilityName = 'Nairobi North Facility'; + + before(async () => { + await loginPage.cookieLogin(); + await commonPage.goToPeople(); + }); + + after(async () => { + await utils.revertDb([/^form:/], true); + }); + + afterEach(async () => { + await commonPage.goToPeople(); + }); + + it('should create health facility, chw area and chw '+ + 'chw supervisor and chw user', async () => { + //create health facility + await generateScreenshot('new-facility', 'select-new-facility'); + await commonPage.clickFastActionFlat({ waitForList: false }); + await commonEnketoPage.selectRadioButton('Set the Primary Contact', 'Skip this step'); + await generateScreenshot('new-facility', 'skip-primary-contact'); + await genericForm.nextPage(); + await commonEnketoPage.setInputValue('Name', healthFacilityName); + await generateScreenshot('new-facility', 'enter-facility-name'); + await genericForm.submitForm({ waitForPageLoaded: false }); + await contactPage.waitForContactLoaded(); + await commonPage.hideSnackbar(); + await generateScreenshot('new-facility', 'created-facility'); + + //create chw area and chw + await commonPage.clickFastActionFAB({ waitForList: false }); + await browser.pause(150); // Waiting for animation to avoid blurry screenshots + await generateScreenshot('new-chw-area', 'new-chw-area'); + await commonPage.closeFastActionList(); + await commonPage.clickFastActionFAB({ actionId: 'health_center' }); + await commonEnketoPage.selectRadioButton('Set the Primary Contact', 'Create a new person'); + await commonEnketoPage.setInputValue('Full Name', 'Jane Doe'); + await commonEnketoPage.selectRadioButton('Set the Primary Contact', 'Create a new person'); + await generateScreenshot('new-chw-area', 'create-new-person'); + await commonEnketoPage.setDateValue('Age', '1990-01-21'); + await commonEnketoPage.selectRadioButton('Sex', 'Male'); + await commonEnketoPage.scrollToQuestion('Age'); + await generateScreenshot('new-chw-area', 'fill-required-fields'); + await commonEnketoPage.selectRadioButton('Role', 'CHW'); + await genericForm.nextPage(); + await commonEnketoPage.selectRadioButton( + 'Would you like to name the place after the primary contact:', + 'Yes' + ); + await generateScreenshot('new-chw-area', 'name-after-primary-contact'); + await genericForm.submitForm({ waitForPageLoaded: false }); + await contactPage.waitForContactLoaded(); + await commonPage.hideSnackbar(); + await generateScreenshot('new-chw-area', 'created-chw-area'); + + //create chw supervisor + await contactPage.selectLHSRowByText(healthFacilityName); + await searchPage.clearSearch(); + await commonPage.clickFastActionFAB({ waitForList: false }); + await browser.pause(150); // Waiting for animation to avoid blurry screenshots + await generateScreenshot('new-chw-supervisor', 'new-person'); + await commonPage.closeFastActionList(); + await commonPage.clickFastActionFAB({ actionId: 'person' }); + await commonEnketoPage.setInputValue('Full name', 'John Doe'); + await generateScreenshot('new-chw-supervisor', 'belongs-to'); + await commonEnketoPage.selectRadioButton('Sex', 'male'); + await commonEnketoPage.setDateValue('Age', '1988-03-07'); + await genericForm.submitForm(); + await contactPage.selectLHSRowByText(healthFacilityName); + await searchPage.clearSearch(); + await commonPage.openMoreOptionsMenu(); + await browser.pause(150); // Waiting for animation to avoid blurry screenshots + await generateScreenshot('new-chw-supervisor', 'edit-facility'); + await commonPage.accessEditOption(true); + await contactPage.openPrimaryContactSearchDropdown(); + await contactPage.inputPrimaryContactSearchValue('John'); + await generateScreenshot('new-chw-supervisor', 'set-primary-contact'); + await contactPage.selectPrimaryContactSearchFirstResult(); + await contactPage.genericForm.submitForm(); + await contactPage.selectLHSRowByText(healthFacilityName); + await searchPage.clearSearch(); + await generateScreenshot('new-chw-supervisor', 'primary-contact-selected'); + + //create chw user + await commonPage.openHamburgerMenu(); + await generateScreenshot('new-chw-user', 'app-settings'); + await commonPage.openAppManagement(); + await usersAdminPage.goToAdminUser(); + await (await usersAdminPage.addUserButton()).waitForDisplayed(); + await generateScreenshot('new-chw-user', 'add-user'); + await usersAdminPage.openAddUserDialog(); + await usersAdminPage.inputAddUserFields( + 'Janet', + '', + 'chw', + `Jane Doe's Area`, + 'John Doe', + 'Secret_1' + ); + await usersAdminPage.scrollToRole(); + await generateScreenshot('new-chw-user', 'fill-user-details'); + }); +}); diff --git a/tests/e2e/visual/contacts/contact-user-management.wdio-spec.js b/tests/e2e/visual/contacts/contact-user-management.wdio-spec.js new file mode 100644 index 00000000000..114e6fedefe --- /dev/null +++ b/tests/e2e/visual/contacts/contact-user-management.wdio-spec.js @@ -0,0 +1,148 @@ +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); +const dataFactory = require('@factories/cht/generate'); +const searchPage = require('@page-objects/default/search/search.wdio.page'); +const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); +const utils = require('@utils'); +const chtConfUtils = require('@utils/cht-conf'); +const path = require('path'); +const { generateScreenshot, isMobile } = require('@utils/screenshots'); + +describe('Contact and User Management', () => { + const updateContactSummarySettings = async () => { + await chtConfUtils.initializeConfigDir(); + const contactSummaryFile = path.join(__dirname, 'config/contact-summary.templated.js'); + const contactSummaryExtrasFile = path.join(__dirname, 'config/contact-summary-extras.js'); + const { contactSummary } = await chtConfUtils.compileNoolsConfig({ + contactSummary: contactSummaryFile, + contactSummaryExtras: contactSummaryExtrasFile + }); + await utils.updateSettings( + { contact_summary: contactSummary }, + { revert: true, ignoreReload: true, refresh: true, sync: true } + ); + await commonPage.waitForPageLoaded(); + }; + + const compileAndUploadForms = async () => { + await chtConfUtils.initializeConfigDir(); + const formsPath = path.join(__dirname, 'forms'); + await chtConfUtils.compileAndUploadAppForms(formsPath); + }; + + const docs = dataFactory.createHierarchy({ + name: 'Janet Mwangi', + user: true, + nbrClinics: 10, + nbrPersons: 4, + useRealNames: true, + }); + + before(async () => { + await utils.saveDocs([...docs.places, ...docs.clinics, ...docs.persons, ...docs.reports]); + await utils.createUsers([docs.user]); + await utils.addTranslations('en', {'contact.last.visit.unknown': 'Last Visited'}); + await loginPage.login(docs.user); + await updateContactSummarySettings(); + }); + + after(async () => { + await utils.deleteUsers([docs.user]); + await utils.revertDb([/^form:/], true); + await utils.revertSettings(); + }); + + afterEach(async () => { + await commonPage.goToBase(); + }); + + describe('Contact and user overview', () => { + it('should show contacts list, search, profiles (person and family), '+ + 'contact summary', async function() { + await commonPage.goToPeople(); + expect(await commonPage.isPeopleListPresent()).to.be.true; + await generateScreenshot('people', 'list'); + await searchPage.performSearch('Beatrice'); + await generateScreenshot('people', 'search'); + await searchPage.clearSearch(); + await contactPage.selectLHSRowByText('Beatrice Bass Family'); + await searchPage.clearSearch(); + await generateScreenshot('people', 'profile-family'); + await commonPage.goToBase(); + await commonPage.goToPeople(); + await contactPage.selectLHSRowByText('Beatrice Bass'); + await generateScreenshot('people', 'profile-person'); + }); + + it('should show condition cards', async function() { + if (!await isMobile()){ + this.skip(); + } + await commonPage.goToPeople(); + expect(await commonPage.isPeopleListPresent()).to.be.true; + await contactPage.selectLHSRowByText('Beatrice Bass'); + await (await contactPage.pregnancyCardSelectors.pregnancyCard()).scrollIntoView(); + await generateScreenshot('people', 'condition-card-active-pregnancy'); + await commonPage.goToBase(); + await commonPage.goToPeople(); + await contactPage.selectLHSRowByText('John Bass'); + await (await contactPage.inmunizationCardSelectors.inmunizationCard()).scrollIntoView(); + await generateScreenshot('people', 'condition-card-inmunization'); + }); + + it('should show profiles (area and branch)', async function() { + if (!await isMobile()){ + this.skip(); + } + await commonPage.logout(); + await loginPage.cookieLogin(); + await commonPage.goToPeople(); + expect(await commonPage.isPeopleListPresent()).to.be.true; + await contactPage.selectLHSRowByText(`Janet Mwangi's Area`); + await generateScreenshot('people', 'profile-area'); + await commonPage.goToBase(); + await commonPage.goToPeople(); + await contactPage.selectLHSRowByText(`Kiambu Branch`); + await generateScreenshot('people', 'profile-branch'); + await commonPage.goToBase(); + await commonPage.logout(); + await loginPage.login(docs.user); + }); + + it('should show UHC sort', async function() { + if (!await isMobile()){ + this.skip(); + } + await utils.updatePermissions(docs.user.roles, ['can_view_uhc_stats', 'can_view_last_visited_date'], [], { + ignoreReload: true, + revert: true, + refresh: true, + sync: true + }); + await commonPage.waitForPageLoaded(); + await commonPage.goToPeople(); + await contactPage.selectSortOrder('By date last visited'); + await contactPage.openSortMenu(); + await generateScreenshot('people', 'sort'); + }); + + it('should show cares guides', async function() { + if (!await isMobile()){ + this.skip(); + } + await compileAndUploadForms(); + await utils.updatePermissions(docs.user.roles, [], ['can_view_call_action', 'can_view_message_action'], { + ignoreReload: true, + revert: true, + refresh: true, + sync: true + }); + await commonPage.waitForPageLoaded(); + await commonPage.goToPeople(); + expect(await commonPage.isPeopleListPresent()).to.be.true; + await contactPage.selectLHSRowByText('Dana Dearborn'); + await commonPage.clickFastActionFAB({ waitForList: false }); + await generateScreenshot('people', 'care-guides'); + }); + }); +}); diff --git a/tests/e2e/visual/contacts/forms/death_report.properties.json b/tests/e2e/visual/contacts/forms/death_report.properties.json new file mode 100644 index 00000000000..097afebe7be --- /dev/null +++ b/tests/e2e/visual/contacts/forms/death_report.properties.json @@ -0,0 +1,9 @@ +{ + "icon": "icon-death-general", + "title": "Death report", + "context": { + "person": false, + "place": false, + "expression": "contact.type === 'person' && summary.alive && !summary.show_delivery_form && user.parent.type === 'health_center' && (!contact.sex || contact.sex === 'female') && (!contact.date_of_birth || (ageInYears(contact) >= 12 && ageInYears(contact) <= 49)) && user._id != contact._id" + } +} diff --git a/tests/e2e/visual/contacts/forms/death_report.xml b/tests/e2e/visual/contacts/forms/death_report.xml new file mode 100644 index 00000000000..a156627ad94 --- /dev/null +++ b/tests/e2e/visual/contacts/forms/death_report.xml @@ -0,0 +1,285 @@ + + + + Death report + + + + + Date of death can only be from today up to 1 year ago. + + + Date of Death + + + Provide any relevant information related to the death of . + + + Place of death + + + Specify other + + + Death details + + + <div style="text-align:center;">Relevant Information: </div> + + + <b>Important Information</b><i class="fa fa-warning"></i> + + + <div style="text-align:center;"> + +Date of Death: </div> + + + <b>You will never be able to do any follow ups on when you submit this death report.</b> + + + Patient Details<I class="fa fa-user"></i> + + + You will be able to undo this death report later, if needed. + + + <h4 style="text-align:center;">To finish, be sure to click the Submit button.</h4> + + + What is the patient's name? + + + Date of Birth + + + Name + + + Household ID + + + CHW name + + + CHW phone + + + Contact + + + Patient ID + + + Sex + + + Short Name + + + Contact + + + Source + + + Source ID + + + + + + + + + + + + + + + user + + + <_id/> + + + + 0 + + + <_id/> + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + <__date_of_death/> + <__place_of_death/> + <__place_of_death_other/> + <__death_information/> + + <__patient_uuid/> + <__patient_id/> + <__household_uuid/> + <__source/> + <__source_id/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/visual/contacts/forms/pregnancy_danger_sign.properties.json b/tests/e2e/visual/contacts/forms/pregnancy_danger_sign.properties.json new file mode 100644 index 00000000000..66449fc0494 --- /dev/null +++ b/tests/e2e/visual/contacts/forms/pregnancy_danger_sign.properties.json @@ -0,0 +1,9 @@ +{ + "icon": "icon-pregnancy-danger", + "title": "Pregnancy danger sign", + "context": { + "person": false, + "place": false, + "expression": "contact.type === 'person' && summary.alive && !summary.muted && summary.is_active_pregnancy && user.parent.type === 'health_center' && (!contact.sex || contact.sex === 'female') && (!contact.date_of_birth || (ageInYears(contact) >= 12 && ageInYears(contact) <= 49)) && user._id != contact._id" + } +} diff --git a/tests/e2e/visual/contacts/forms/pregnancy_danger_sign.xml b/tests/e2e/visual/contacts/forms/pregnancy_danger_sign.xml new file mode 100644 index 00000000000..77e55b1d6d2 --- /dev/null +++ b/tests/e2e/visual/contacts/forms/pregnancy_danger_sign.xml @@ -0,0 +1,518 @@ + + + + Pregnancy danger sign + + + + + No + + + Yes + + + Breaking of water + + + No + + + Yes + + + Breathlessness + + + Great news! Please closely monitor her until her next scheduled pregnancy visit. + + + the woman + + + The woman + + + Ask to monitor these danger signs throughout the pregnancy. + + + Does currently have any of these danger signs? + + + No + + + Yes + + + Getting tired easily + + + No + + + Yes + + + Swelling of face and hands + + + No + + + Yes + + + Fever + + + No + + + Yes + + + Fits + + + No + + + Yes + + + Reduced or no fetal movements + + + <span style="color:red">Please refer to the health facility immediately. Accompany her if possible.</span> + + + <span style="color:red">Please complete the follow-up task within 3 days.</span> + + + No + + + Yes + + + Severe abdominal pain + + + No + + + Yes + + + Severe headache + + + No + + + Yes + + + Vaginal bleeding + + + No + + + Yes + + + Very pale + + + Danger Sign Check + + + What is the patient's name? + + + Date of Birth + + + Name + + + Parent ID + + + CHW name + + + CHW phone + + + Patient ID + + + Sex + + + Short Name + + + Contact + + + Source + + + Source ID + + + + + + + + + + + + + + + user + + + <_id/> + + + + 0 + + + <_id/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <__vaginal_bleeding/> + <__fits/> + <__severe_abdominal_pain/> + <__severe_headache/> + <__very_pale/> + <__fever/> + <__reduced_or_no_fetal_movements/> + <__breaking_water/> + <__easily_tired/> + <__face_hand_swelling/> + <__breathlessness/> + <__has_danger_sign/> + + <__patient_uuid/> + <__patient_id/> + <__household_uuid/> + <__source/> + <__source_id/> + <__pregnancy_uuid/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/visual/contacts/forms/pregnancy_home_visit.properties.json b/tests/e2e/visual/contacts/forms/pregnancy_home_visit.properties.json new file mode 100644 index 00000000000..0f4033b8a6d --- /dev/null +++ b/tests/e2e/visual/contacts/forms/pregnancy_home_visit.properties.json @@ -0,0 +1,9 @@ +{ + "icon": "icon-pregnancy", + "title": "ANC visit", + "context": { + "person": true, + "place": false, + "expression": "contact.type === 'person' && summary.alive && !summary.muted && summary.is_active_pregnancy && user.parent.type === 'health_center' && (!contact.sex || contact.sex === 'female') && (!contact.date_of_birth || (ageInYears(contact) >= 12 && ageInYears(contact) <= 49)) && user._id != contact._id" + } +} diff --git a/tests/e2e/visual/contacts/forms/pregnancy_home_visit.xml b/tests/e2e/visual/contacts/forms/pregnancy_home_visit.xml new file mode 100644 index 00000000000..eb7bcac4f04 --- /dev/null +++ b/tests/e2e/visual/contacts/forms/pregnancy_home_visit.xml @@ -0,0 +1,2247 @@ + + + + Pregnancy home visit + + + + + Date cannot be in the past. Date cannot be more than one month from today. + + + I don't know + + + Enter date + + + If has a specific upcoming ANC appointment date, enter it here. You will receive a task three days before to remind her to attend. + + + is ** weeks** pregnant. + + + Please refer to the health facility at the appropriate time. + + + The WHO recommends ANC visits at 12, 20, 26, 30, 34, 36, 38, 40 weeks. + + + ANC Visits at Health Facility (Upcoming) + + + No + + + Yes + + + Did the woman complete the health facility ANC visit scheduled for ? + + + No + + + Yes + + + Would you like to report any additional unreported health facility ANC visits? + + + I don't know + + + Enter date + + + Please enter the date if you know it. + + + Enter the correct date. Date must be within this pregnancy and cannot be in the future! + + + Date + + + Enter the correct date. Date must be within this pregnancy and cannot be in the future! + + + Date + + + I don't know + + + Enter date + + + Visit + + + Please enter the dates if you have them. + + + Each "Visit" section below asks about one individual visit. Please complete all sections. + + + Enter 0 if she has not been yet. + + + Must be an integer between 0 and 9. + + + How many? + + + ANC Visits at Health Facility (Past) + + + max characters = 100 + + + If yes, please describe. + + + No + + + Yes + + + Are there additional factors that could make this pregnancy high-risk? + + + Asthma + + + Diabetes + + + + + + First pregnancy + + + Heart condition + + + High blood pressure + + + Last baby born less than one year ago + + + Has delivered four or more children + + + Select all that apply. + + + If "None of the above" selected, cannot select any other option. + + + Does have additional risk factors that you have not previously reported? + + + None + + + Previous difficulties in childbirth + + + Previous miscarriages or stillbirths + + + You previously reported the following risk factors: + + + Risk Factors + + + No + + + Yes + + + Breaking of water + + + No + + + Yes + + + Breathlessness + + + Great news! Please closely monitor her until her next scheduled pregnancy visit. + + + Ask to monitor these danger signs throughout the pregnancy. + + + Does currently have any of these danger signs? + + + No + + + Yes + + + Getting tired easily + + + No + + + Yes + + + Swelling of face and hands + + + No + + + Yes + + + Fever + + + No + + + Yes + + + Fits + + + No + + + Yes + + + Reduced or no fetal movements + + + <span style="color:red">Please refer to the health facility immediately. Accompany her if possible.</span> + + + <span style="color:red">Please complete the follow-up task within 3 days.</span> + + + No + + + Yes + + + Severe abdominal pain + + + No + + + Yes + + + Severe headache + + + No + + + Yes + + + Vaginal bleeding + + + No + + + Yes + + + Very pale + + + Danger Sign Check + + + What is the patient's name? + + + Date of Birth + + + Name + + + Parent ID + + + CHW name + + + CHW phone + + + Patient ID + + + Sex + + + Short Name + + + Source + + + Source ID + + + Date cannot be in the future. Date cannot be older than LMP. + + + Date of abortion + + + You have reported the woman has had an abortion. If she has not been seen by a care provider, please refer to the health facility. + + + Click "< Prev" to go back. + + + You can still submit pregnancy visits from the profile until the EDD is reached. + + + Do not receive any more tasks about this pregnancy. + + + Clear task for this visit only. Continue to receive tasks for this pregnancy. + + + What would you like to do? + + + Submitting this form will end the pregnancy. You will not receive any additional tasks. + + + You have reported the woman has moved out of the area. + + + Date cannot be in the future. Date cannot be older than LMP. + + + Date of miscarriage + + + You have reported the woman has had a miscarriage. If she has not been seen by a care provider, please refer to the health facility. + + + You have reported the woman has refused care. + + + Click Submit to confirm. + + + Update Pregnancy + + + Expected Date of Delivery(EDD): **** + + + Expected Date of Delivery(EDD): **unknown** + + + Current Weeks Pregnant: **** + + + Current Weeks Pregnant: **unknown** + + + No, I want to update. + + + Yes, it is correct. + + + Is the gestational age above correct? + + + No, Abortion + + + No, Migrated out of area + + + No, Miscarriage + + + No, Refusing care + + + Yes + + + Select one. + + + Do you want to start this pregnancy visit? + + + Pregnancy Summary + + + No + + + Yes + + + Has received deworming medication? + + + Worms can affect the nutritional status of and baby. + + + Frequent testing ensures that receives medicine to prevent transmission of HIV to the baby. + + + No + + + Yes + + + Has been tested for HIV in the past 3 months? + + + No + + + Yes + + + Is taking iron folate daily? + + + Iron folate aids in the development of child's brain and spinal cord. It also prevents premature birth, sepsis, anemia and low birth weight. + + + Sleeping under a LLIN **EVERY night** prevents malaria. + + + No + + + Yes + + + Does use a long-lasting insecticidal net (LLIN)? + + + Get malaria prophylaxis in second trimester if living in malaria endemic area. + + + It's safest to deliver in a health facility. Discuss a birth plan with . + + + Eat more often than usual and a balanced diet to give you strength and help the baby grow. + + + Respond to the baby's movements-kicks by gentle touching or massaging your tummy. + + + Talk softly to the unborn baby. The baby can hear you and will be able to recognize voices. + + + No + + + Yes + + + Has received any Tetanus Toxoid (TT) immunizations during this pregnancy? + + + Immunizing with at least two doses of tetanus toxoid before or during pregnancy protects the newborn for the first few weeks of life and protects the mother. + + + Women can receive up to two TT vaccines per pregnancy. After five TT vaccines, they are vaccinated for life. + + + Safe Pregnancy Practices + + + the woman + + + The woman + + + Breaking of water + + + Breathlessness + + + Getting tired easily + + + Swelling of face and hands + + + Fever + + + Fits + + + Reduced or no fetal movements + + + Severe abdominal pain + + + Severe headache + + + Vaginal bleeding + + + Very pale + + + New Danger Signs + + + Follow-up Tasks<I class="fa fa-flag"></i> + + + The following tasks will appear: + + + Please conduct a danger sign follow-up in 3 days. + + + Make sure attends her clinic visit on . Please remind her three days before. + + + Please conduct the next pregnancy home visit in week(s). + + + Please conduct the next pregnancy home visit in 2 weeks. + + + <h2 style="text-align:center;margin-bottom:0px;"></h2> <p style="text-align:center;"> years old</p> + + + <p> weeks pregnant.</p> <p> EDD: </p> + + + Unknown weeks pregnant. + + + Refer to clinic immediately for: + + + Danger Sign + + + Please refer to the health facility at the appropriate time. + + + Please refer to the health facility immediately to receive the EDD and appropriate care. + + + Referrals<I class="fa fa-hospital-o"></i> + + + Deworming + + + HIV test + + + Iron folate + + + TT + + + Request the following services: + + + + + + Asthma + + + Diabetes + + + New Risk Factors + + + Heart condition + + + High blood pressure + + + Please attend ANC on: + + + <h4 style="text-align:center;">Click the Submit button at the bottom of the form.</h4> + + + Summary<I class="fa fa-user"></i> + + + Patient<i class="fa fa-user"></i> + + + The WHO recommends routine ANC visits at 12, 20, 26, 30, 34, 36, 38, 40 weeks. + + + Expected date of delivery + + + Current weeks pregnant + + + Select one. + + + How would you like to update gestational age? + + + Must be between 4 and 40. + + + Please enter the new current weeks pregnant. + + + Date cannot be in the past. Date cannot be more than 9 months in the future. + + + Please enter the new EDD. + + + If this seems incorrect, click "< Prev" to update the pregnancy information. + + + EDD: + + + weeks pregnant + + + **New** + + + EDD: + + + EDD: unknown + + + weeks pregnant + + + Unknown weeks pregnant + + + **Previous** + + + **Please confirm the new information and then click Next.** + + + Update Pregnancy + + + Yes, it is correct. + + + No, I want to update. + + + Clear task for this visit only. Continue to receive tasks for this pregnancy. + + + Do not receive any more tasks about this pregnancy. + + + Current weeks pregnant + + + Expected date of delivery + + + I don't know + + + Enter date + + + Weeks + + + Months + + + The woman is visibly pregnant but does not know of how long. + + + You performed a pregnancy test and it is positive but the woman does not know the age of the pregnancy or LMP. + + + The woman is not on any family planning methods and has missed her periods. + + + Heart condition + + + Asthma + + + High blood pressure + + + Diabetes + + + None of the above + + + the woman + + + The woman + + + Yes + + + No, Miscarriage + + + No, Abortion + + + No, Refusing care + + + No, Migrated out of area + + + Yes + + + No + + + + + + + + + + + + + + + user + + + <_id/> + + + + 0 + + + <_id/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <__activity_to_report/> + <__gestational_age_correct/> + <__miscarriage_date/> + <__abortion_date/> + <__visit_task_clear_option/> + <__gestational_age_update_method/> + <__gestational_age_update_weeks/> + <__gestational_age_update_edd/> + <__lmp_updated/> + <__lmp_date_new/> + <__edd_new/> + <__last_visit_attended/> + <__report_additional_anc_hf_visits/> + <__num_additional_anc_hf_visits/> + <__additional_anc_hf_visit_dates/> + <__has_risk_factors_not_previously_reported/> + <__heart_condition/> + <__asthma/> + <__high_blood_pressure/> + <__diabetes/> + <__additional_high_risk_condition_to_report/> + <__additional_high_risk_condition/> + <__next_anc_hf_visit_date_known/> + <__next_anc_hf_visit_date/> + <__vaginal_bleeding/> + <__fits/> + <__severe_abdominal_pain/> + <__severe_headache/> + <__very_pale/> + <__fever/> + <__reduced_or_no_fetal_movements/> + <__breaking_water/> + <__easily_tired/> + <__face_hand_swelling/> + <__breathlessness/> + <__has_danger_sign/> + <__uses_llin/> + <__takes_iron_folate_daily/> + <__received_deworming_meds/> + <__tested_for_hiv_in_past_3_months/> + <__received_tetanus_toxoid_this_pregnancy/> + + <__patient_uuid/> + <__patient_id/> + <__household_uuid/> + <__source/> + <__source_id/> + <__pregnancy_uuid/> + + + + + + + + + + + + static_instance-yes_no-0 + yes + + + static_instance-yes_no-1 + no + + + + + + + static_instance-visit_options-0 + yes + + + static_instance-visit_options-1 + miscarriage + + + static_instance-visit_options-2 + abortion + + + static_instance-visit_options-3 + refused + + + static_instance-visit_options-4 + migrated + + + + + + + static_instance-age_correct_yes_no-0 + yes + + + static_instance-age_correct_yes_no-1 + no + + + + + + + static_instance-clear_options-0 + clear_this + + + static_instance-clear_options-1 + clear_all + + + + + + + static_instance-g_age_update_methods-0 + method_weeks + + + static_instance-g_age_update_methods-1 + method_edd + + + + + + + static_instance-lmp_approximations-0 + approx_weeks + + + static_instance-lmp_approximations-1 + approx_months + + + + + + + static_instance-no_info_pregnancy_reasons-0 + visibly_pregnant + + + static_instance-no_info_pregnancy_reasons-1 + test_positive + + + static_instance-no_info_pregnancy_reasons-2 + missed_periods + + + + + + + static_instance-knows_date-0 + no + + + static_instance-knows_date-1 + yes + + + + + + + static_instance-risk_conditions-0 + heart_condition + + + static_instance-risk_conditions-1 + asthma + + + static_instance-risk_conditions-2 + high_blood_pressure + + + static_instance-risk_conditions-3 + diabetes + + + static_instance-risk_conditions-4 + none + + + + + + + static_instance-translate_woman_label-0 + woman + + + + + + + static_instance-translate_woman_start_label-0 + woman-start + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/visual/contacts/list-view-login-visual.wdio-spec.js b/tests/e2e/visual/contacts/list-view-login-visual.wdio-spec.js index a6925ca6fd4..9b0d0e13288 100644 --- a/tests/e2e/visual/contacts/list-view-login-visual.wdio-spec.js +++ b/tests/e2e/visual/contacts/list-view-login-visual.wdio-spec.js @@ -3,21 +3,12 @@ const contactPage = require('@page-objects/default/contacts/contacts.wdio.page') const loginPage = require('@page-objects/default/login/login.wdio.page'); const dataFactory = require('@factories/cht/generate'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); +const searchPage = require('@page-objects/default/search/search.wdio.page'); const utils = require('@utils'); const { resizeWindowForScreenshots, generateScreenshot } = require('@utils/screenshots'); describe('Contact List Page', () => { - const updateRolePermissions = async (roleValue, addPermissions, removePermissions = []) => { - const roles = [roleValue]; - const settings = await utils.getSettings(); - const permissions = await utils.getUpdatedPermissions(roles, addPermissions, removePermissions); - await utils.updateSettings( - { roles: settings.roles, permissions }, - { revert: true, ignoreReload: true, refresh: true, sync: true } - ); - }; - const docs = dataFactory.createHierarchy({ name: 'Janet Mwangi', user: true, @@ -45,89 +36,101 @@ describe('Contact List Page', () => { await commonPage.logout(); }); - describe('Log in', () => { - it('should show contacts page tab '+ - 'when can_view_contact and can_view_contacts_tab permissions are enabled', async () => { - await (await commonPage.contactsTab()).waitForDisplayed(); - await generateScreenshot('contact-page', 'tab-visible'); - await commonPage.openHamburgerMenu(); - await generateScreenshot('contact-page', 'menu-opened'); - await commonPage.closeHamburgerMenu(); - await commonPage.goToPeople(); - expect(await commonPage.isPeopleListPresent()).to.be.true; - await generateScreenshot('contact-page', 'people-list-visible'); - await commonPage.goToReports(); - await reportsPage.openFirstReport(); - await reportsPage.rightPanelSelectors.patientName().waitForClickable(); - await generateScreenshot('contact-page', 'reports-visible'); - await reportsPage.rightPanelSelectors.patientName().click(); - await contactPage.waitForContactLoaded(); - await generateScreenshot('contact-page', 'contact-loaded'); - await commonPage.goToMessages(); - }); + it('should show contacts page tab '+ + 'when can_view_contact and can_view_contacts_tab permissions are enabled', async () => { + expect(await commonPage.isContactTabPresent()).to.be.true; + await generateScreenshot('contact-page', 'tab-visible'); + await commonPage.openHamburgerMenu(); + await generateScreenshot('contact-page', 'menu-opened'); + await commonPage.closeHamburgerMenu(); + await commonPage.goToPeople(); + expect(await commonPage.isPeopleListPresent()).to.be.true; + await generateScreenshot('contact-page', 'people-list-visible'); + await commonPage.goToReports(); + await searchPage.performSearch('Amanda Allen'); + await commonPage.waitForLoaders(); + await reportsPage.openFirstReport(); + await reportsPage.rightPanelSelectors.patientName().waitForClickable(); + await generateScreenshot('contact-page', 'reports-visible'); + await reportsPage.rightPanelSelectors.patientName().click(); + await contactPage.waitForContactLoaded(); + await generateScreenshot('contact-page', 'contact-loaded'); + await commonPage.goToMessages(); + }); - it('should hide contacts page as tab and from menu option ' + - 'when can_view_contacts_tab permissions is enable but can_view_contact permission is not', async () => { - await updateRolePermissions('chw', [], ['can_view_contacts']); - await commonPage.waitForPageLoaded(); - await commonPage.goToMessages(); - await (await commonPage.contactsTab()).waitForDisplayed({ reverse: true }); - await generateScreenshot('contact-page', 'no-tab-visible'); - await commonPage.openHamburgerMenu(); - await (await commonPage.contactsButton()).waitForClickable({ reverse: true }); - await generateScreenshot('contact-page', 'no-menu-option'); - await commonPage.closeHamburgerMenu(); - await commonPage.goToReports(); - await reportsPage.openFirstReport(); - await (reportsPage.rightPanelSelectors.patientName()).waitForClickable(); - await generateScreenshot('contact-page', 'report-view-no-contacts'); - await reportsPage.rightPanelSelectors.patientName().click(); - await generateScreenshot('contact-page', 'report-no-contact-loaded'); - await commonPage.goToMessages(); + it('should hide contacts page as tab and from menu option ' + + 'when can_view_contacts_tab permissions is enable but can_view_contact permission is not', async () => { + await utils.updatePermissions(docs.user.roles, [], ['can_view_contacts'], { + ignoreReload: true, + revert: true, + refresh: true, + sync: true }); + await commonPage.waitForPageLoaded(); + await commonPage.goToMessages(); + expect(await commonPage.isContactTabPresent()).to.be.false; + await generateScreenshot('contact-page', 'no-tab-visible'); + await commonPage.toggleMenuAndCaptureScreenshot('People', true, 'contact-page', 'no-menu-option'); + await commonPage.goToReports(); + await searchPage.performSearch('Amanda Allen'); + await commonPage.waitForLoaders(); + await reportsPage.openFirstReport(); + await (reportsPage.rightPanelSelectors.patientName()).waitForClickable(); + await generateScreenshot('contact-page', 'report-view-no-contacts'); + await reportsPage.rightPanelSelectors.patientName().click(); + await generateScreenshot('contact-page', 'report-no-contact-loaded'); + await commonPage.goToMessages(); + }); - it('should hide contacts page as tab, show from menu option ' + - 'when can_view_contact permissions is enable but can_view_contact permission is not', async () => { - await updateRolePermissions('chw', ['can_view_contacts'], ['can_view_contacts_tab']); - await commonPage.waitForPageLoaded(); - await commonPage.goToMessages(); - await (await commonPage.contactsTab()).waitForDisplayed({ reverse: true }); - await generateScreenshot('contact-page', 'no-tab-visible'); - await commonPage.openHamburgerMenu(); - await (await commonPage.contactsButton()).waitForClickable(); - await generateScreenshot('contact-page', 'menu-option-visible'); - await (await commonPage.contactsButton()).click(); - expect(await commonPage.isPeopleListPresent()).to.be.true; - await commonPage.waitForPageLoaded(); - await generateScreenshot('contact-page', 'contacts-in-people-list'); - await commonPage.goToReports(); - await reportsPage.openFirstReport(); - await (reportsPage.rightPanelSelectors.patientName()).waitForClickable(); - await generateScreenshot('contact-page', 'report-view-with-contacts'); - await reportsPage.rightPanelSelectors.patientName().click(); - await contactPage.waitForContactLoaded(); - await generateScreenshot('contact-page', 'report-contact-loaded'); - await commonPage.goToMessages(); + it('should hide contacts page as tab, show from menu option ' + + 'when can_view_contact permissions is enable but can_view_contact permission is not', async () => { + await utils.updatePermissions(docs.user.roles, ['can_view_contacts'], ['can_view_contacts_tab'], { + ignoreReload: true, + revert: true, + refresh: true, + sync: true }); + await commonPage.waitForPageLoaded(); + await commonPage.goToMessages(); + expect(await commonPage.isContactTabPresent()).to.be.false; + await generateScreenshot('contact-page', 'no-tab-visible'); + await commonPage.toggleMenuAndCaptureScreenshot('People', false, 'contact-page', 'menu-option-visible'); + expect(await commonPage.isPeopleListPresent()).to.be.true; + await commonPage.waitForPageLoaded(); + await generateScreenshot('contact-page', 'contacts-in-people-list'); + await commonPage.goToReports(); + await searchPage.performSearch('Amanda Allen'); + await commonPage.waitForLoaders(); + await reportsPage.openFirstReport(); + await (reportsPage.rightPanelSelectors.patientName()).waitForClickable(); + await generateScreenshot('contact-page', 'report-view-with-contacts'); + await reportsPage.rightPanelSelectors.patientName().click(); + await contactPage.waitForContactLoaded(); + await generateScreenshot('contact-page', 'report-contact-loaded'); + await commonPage.goToMessages(); + }); - it('should hide contacts page as a tab and from menu option ' + - 'when can_view_contact and can_view_contact permissions are disable', async () => { - await updateRolePermissions('chw', [], ['can_view_contacts_tab', 'can_view_contacts']); - await commonPage.waitForPageLoaded(); - await commonPage.goToMessages(); - await (await commonPage.contactsTab()).waitForDisplayed({ reverse: true }); - await generateScreenshot('contact-page', 'no-tab-visible-oPerms'); - await commonPage.openHamburgerMenu(); - await (await commonPage.contactsButton()).waitForClickable({ reverse: true }); - await generateScreenshot('contact-page', 'no-menu-option-no-Perms'); - await commonPage.closeHamburgerMenu(); - await commonPage.goToReports(); - await reportsPage.openFirstReport(); - await (reportsPage.rightPanelSelectors.patientName()).waitForClickable(); - await generateScreenshot('contact-page', 'report-view-no-contacts-no-perms'); - await reportsPage.rightPanelSelectors.patientName().click(); - await generateScreenshot('contact-page', 'report-no-contact-loaded-no-perms'); - await commonPage.goToMessages(); + it('should hide contacts page as a tab and from menu option ' + + 'when can_view_contact and can_view_contact permissions are disable', async () => { + await utils.updatePermissions(docs.user.roles, [], ['can_view_contacts_tab', 'can_view_contacts'], { + ignoreReload: true, + revert: true, + refresh: true, + sync: true }); + await commonPage.waitForPageLoaded(); + await commonPage.goToMessages(); + expect(await commonPage.isContactTabPresent()).to.be.false; + await generateScreenshot('contact-page', 'no-tab-visible-oPerms'); + await commonPage.toggleMenuAndCaptureScreenshot('People', true, 'contact-page', 'no-menu-option-no-Perms'); + await commonPage.goToReports(); + await searchPage.performSearch('Amanda Allen'); + await commonPage.waitForLoaders(); + await reportsPage.openFirstReport(); + await (reportsPage.rightPanelSelectors.patientName()).waitForClickable(); + await generateScreenshot('contact-page', 'report-view-no-contacts-no-perms'); + await reportsPage.rightPanelSelectors.patientName().click(); + await generateScreenshot('contact-page', 'report-no-contact-loaded-no-perms'); + await commonPage.goToMessages(); }); }); diff --git a/tests/e2e/visual/wdio.conf.js b/tests/e2e/visual/wdio.conf.js index 1ba9009f00a..69af6994252 100644 --- a/tests/e2e/visual/wdio.conf.js +++ b/tests/e2e/visual/wdio.conf.js @@ -1,4 +1,5 @@ const wdioBaseConfig = require('../../wdio.conf'); +const { resizeWindowForScreenshots } = require('@utils/screenshots'); const chai = require('chai'); chai.use(require('chai-exclude')); @@ -9,9 +10,9 @@ const mobileCapability = { ...wdioBaseConfig.config.capabilities[0]['goog:chromeOptions'], args: [ ...wdioBaseConfig.config.capabilities[0]['goog:chromeOptions'].args, - 'window-size=450,700', + 'window-size=375,850', ], - }, + } }; const desktopCapability = { @@ -20,17 +21,27 @@ const desktopCapability = { ...wdioBaseConfig.config.capabilities[0]['goog:chromeOptions'], args: [ ...wdioBaseConfig.config.capabilities[0]['goog:chromeOptions'].args, - 'window-size=1000,800', + 'window-size=1440, 1024', ], - }, + } }; exports.config = Object.assign(wdioBaseConfig.config, { suites: { - all: [ - './**/*.wdio-spec.js', + desktopTests: [ + 'contacts/contact-user-management.wdio-spec.js', + 'contacts/contact-user-hierarchy-creation.wdio-spec.js', + ], + mobileTests: [ + 'contacts/contact-user-management.wdio-spec.js', + 'contacts/list-view-login-visual.wdio-spec.js', ] }, - capabilities: [mobileCapability, desktopCapability], + capabilities: process.argv.includes('--suite=mobileTests') + ? [mobileCapability] + : [desktopCapability], maxInstances: 1, + beforeSuite: async function () { + await resizeWindowForScreenshots(); + }, }); diff --git a/tests/factories/cht/generate.js b/tests/factories/cht/generate.js index 86ebbac7d13..cd73edd2c2e 100644 --- a/tests/factories/cht/generate.js +++ b/tests/factories/cht/generate.js @@ -4,10 +4,22 @@ const personFactory = require('@factories/cht/contacts/person'); const deliveryFactory = require('@factories/cht/reports/delivery'); const pregnancyFactory = require('@factories/cht/reports/pregnancy'); const pregnancyVisitFactory = require('@factories/cht/reports/pregnancy-visit'); +const immunizationFactory = require('@factories/cht/reports/inmunization'); +const { faker } = require('@faker-js/faker'); // Fixed collection of real-world data -const FIRST_NAMES = ['Amanda', 'Beatrice', 'Dana', 'Fatima', 'Gina', 'Helen', 'Isabelle', 'Jessica', 'Ivy', 'Sara']; -const LAST_NAMES = ['Allen', 'Bass', 'Dearborn', 'Flair', 'Gorman', 'Hamburg', 'Ivanas', 'James', 'Moore', 'Taylor']; +const PRIMARY_CONTACT_FIRST_NAMES = [ + 'Amanda', 'Beatrice', 'Dana', 'Fatima', + 'Gina', 'Helen', 'Isabelle', 'Jessica', + 'Ivy', 'Sara' +]; +const ADDITIONAL_KID_FIRST_NAMES = ['John', 'Timmy', 'Elias']; +const ADDITIONAL_WOMAN_FIRST_NAMES = ['Hawa', 'Ana', 'Tania']; +const FAMILY_LAST_NAMES = [ + 'Allen', 'Bass', 'Dearborn', 'Flair', + 'Gorman', 'Hamburg', 'Ivanas', 'James', + 'Moore', 'Taylor' +]; const PHONE_NUMBERS = [ '+256414345783', '+256414345784', '+256414345785', '+256414345786', '+256414345787', '+256414345788', @@ -16,12 +28,37 @@ const PHONE_NUMBERS = [ ]; const PATIENT_IDS = [65421, 65422, 65423, 65424, 65425, 65426, 65427, 65428, 65429, 65430]; +const calculateDateOfBirth = (age) => { + const today = new Date(); + const birthYear = today.getFullYear() - age; + const birthMonth = today.getMonth() + 1; + const birthDay = today.getDate(); + return `${birthYear}-${String(birthMonth).padStart(2, '0')}-${String(birthDay).padStart(2, '0')}`; +}; +const KIDS_AGES = [2, 7, 10]; +const ADULTS_AGES = [25, 35]; +const DATE_OF_BIRTHS_KIDS = KIDS_AGES.map(calculateDateOfBirth); +const DATE_OF_BIRTHS_ADULTS = ADULTS_AGES.map(calculateDateOfBirth); + +const calculateLastMenstrualPeriod = (date) => { + const PREGNANCY_DAYS = 252; + date.setDate(date.getDate() - PREGNANCY_DAYS); + return date.toISOString().split('T')[0]; // YYYY-MM-DD +}; + const getReportContext = (patient, submitter) => { + const daysAgo = faker.number.int({ min: 1, max: 10 }); + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - daysAgo); + const lastMestrualPeriod = calculateLastMenstrualPeriod(new Date()); const context = { fields: { patient_id: patient._id, patient_uuid: patient._id, patient_name: patient.name, + visited_contact_uuid: patient.parent._id, + visited_date: currentDate, + lmp_date_8601: lastMestrualPeriod, }, }; if (submitter) { @@ -61,20 +98,22 @@ const createDataWithFixedData = ({ healthCenter, user, nbrClinics = 10, nbrPerso }; const createClinic = (index, healthCenter) => { - const firstName = FIRST_NAMES[index % FIRST_NAMES.length]; - const lastName = LAST_NAMES[index % LAST_NAMES.length]; + const firstName = PRIMARY_CONTACT_FIRST_NAMES[index % PRIMARY_CONTACT_FIRST_NAMES.length]; + const lastName = FAMILY_LAST_NAMES[index % FAMILY_LAST_NAMES.length]; const personName = `${firstName} ${lastName}`; const personPhoneNumber = PHONE_NUMBERS[index % PHONE_NUMBERS.length]; const primaryContact = personFactory.build({ name: personName, - phone: personPhoneNumber + phone: personPhoneNumber, + patient_id: PATIENT_IDS[0], }); const clinic = placeFactory.place().build({ type: 'clinic', parent: { _id: healthCenter._id, parent: healthCenter.parent }, name: `${personName} Family`, + last_name: lastName, contact: primaryContact }); @@ -83,26 +122,51 @@ const createClinic = (index, healthCenter) => { return { clinic, primaryContact }; }; -const createAdditionalPersons = (nbrPersons, clinic) => { +const createAdditionalPersons = (nbrPersons, clinic, nameList, dateOfBirthList) => { return Array - .from({ length: nbrPersons - 1 }) + .from({ length: nbrPersons }) .map((_, i) => { - const additionalPersonName = `${FIRST_NAMES[i % FIRST_NAMES.length]} ${LAST_NAMES[i % LAST_NAMES.length]}`; const additionalPhoneNumber = PHONE_NUMBERS[i % PHONE_NUMBERS.length]; + const name = nameList[i % nameList.length]; + const date_of_birth = dateOfBirthList[i % dateOfBirthList.length]; return personFactory.build({ parent: { _id: clinic._id, parent: clinic.parent }, - name: additionalPersonName, + name: `${name} ${clinic.last_name}`, patient_id: PATIENT_IDS[i % PATIENT_IDS.length], - phone: additionalPhoneNumber + phone: additionalPhoneNumber, + date_of_birth: date_of_birth, }); }); }; -const createReportsForPerson = (person, user) => { +const createAdditionalKid = (nbrPersons, clinic) => { + return createAdditionalPersons( + nbrPersons, + clinic, + ADDITIONAL_KID_FIRST_NAMES, + DATE_OF_BIRTHS_KIDS + ); +}; + +const createAdditionalWoman = (nbrPersons, clinic) => { + return createAdditionalPersons( + nbrPersons, + clinic, + ADDITIONAL_WOMAN_FIRST_NAMES, + DATE_OF_BIRTHS_ADULTS, + ); +}; + +const createReportsForWoman = (person, user) => { return [ - deliveryFactory.build(getReportContext(person, user)), pregnancyFactory.build(getReportContext(person, user)), - pregnancyVisitFactory.build(getReportContext(person, user)) + pregnancyVisitFactory.build(getReportContext(person, user)), + ]; +}; + +const createReportsForKid = (person, user) => { + return [ + immunizationFactory.build({ contact: user, patient: person }) ]; }; @@ -112,19 +176,28 @@ const createDataWithRealNames = ({ healthCenter, user, nbrClinics = 10, nbrPerso .map((_, index) => { const { clinic, primaryContact } = createClinic(index, healthCenter); - const additionalPersons = createAdditionalPersons(nbrPersons, clinic); - - const allPersons = [primaryContact, ...additionalPersons]; + const kids = createAdditionalKid( Math.floor(nbrPersons / 2), clinic); + const adults = createAdditionalWoman( Math.floor(nbrPersons / 2), clinic); + adults.unshift(primaryContact); - return { clinic, persons: allPersons }; + return { clinic, kids, adults }; }); - const allPersons = clinicsData.flatMap(data => data.persons); + const allPersons = clinicsData.flatMap(data => ([...data.kids, ...data.adults])); const clinicList = clinicsData.map(data => data.clinic); - const reports = allPersons.flatMap(person => createReportsForPerson(person, user)); + const reportsForKids = clinicsData + .flatMap(data => data.kids) + .flatMap(person => createReportsForKid(person, user)); + const reportsForWoman = clinicsData + .flatMap(data => data.adults) + .flatMap(person => createReportsForWoman(person, user)); - return { clinics: clinicList, reports, persons: allPersons }; + return { + clinics: clinicList, + reports: [...reportsForKids, ...reportsForWoman], + persons: [...allPersons] + }; }; const createData = ({ healthCenter, user, nbrClinics, nbrPersons, useRealNames = false }) => { @@ -137,14 +210,31 @@ const createData = ({ healthCenter, user, nbrClinics, nbrPersons, useRealNames = const createHierarchy = ({ name, user = false, nbrClinics = 50, nbrPersons = 10, useRealNames = false }) => { const hierarchy = placeFactory.generateHierarchy(); const healthCenter = hierarchy.get('health_center'); - user = user && userFactory.build({ place: healthCenter._id, roles: ['chw'] }); + healthCenter.name = `${name}'s Area`; + const branch = hierarchy.get('district_hospital'); + branch.name = 'Kiambu Branch'; + const branchContact = { + _id: 'fixture:user:user2', + name: 'Manager Ann', + phone: '+123456789' + }; + branch.contact = branchContact; + const contact = { + _id: 'fixture:user:user1', + name: name, + phone: '+12068881234' + }; + healthCenter.contact = contact; + + user = user && userFactory.build({ place: healthCenter._id, roles: ['chw'], contact: contact }); const places = [...hierarchy.values()].map(place => { - place.name = `${name} ${place.type}`; + if (place.type === 'clinic') { + place.name = `${name} ${place.type}`; + } return place; }); - healthCenter.name = `${name}'s Area`; const { clinics, reports, persons } = createData({ healthCenter, nbrClinics, nbrPersons, user, useRealNames }); return { @@ -161,4 +251,5 @@ module.exports = { createHierarchy, createData, ids: docs => docs.map(doc => doc._id || doc.id), + PATIENT_IDS, }; diff --git a/tests/factories/cht/reports/inmunization.js b/tests/factories/cht/reports/inmunization.js new file mode 100644 index 00000000000..0d3a3a17333 --- /dev/null +++ b/tests/factories/cht/reports/inmunization.js @@ -0,0 +1,73 @@ +const { v4: uuidv4 } = require('uuid'); + +const immunizationVisitFactory = { + build: ({ contact, patient }) => ({ + _id: uuidv4(), + form: 'immunization_visit', + type: 'data_record', + content_type: 'xml', + reported_date: Date.now(), + contact: contact, + fields: { + inputs: { + source: 'contact', + contact: { + _id: patient._id, + patient_id: patient.patient_id, + name: patient.name, + date_of_birth: patient.date_of_birth, + sex: patient.sex, + phone: patient.phone, + parent: { + contact: { + name: patient.name + } + } + } + }, + patient_age_in_years: '2', + patient_phone: patient.phone || '', + patient_uuid: patient._id, + patient_id: patient.patient_id, + patient_name: patient.name, + chw_name: patient.name, + chw_sms: `Nice work`, + visit_confirmed: 'yes', + vaccines_received: { + received_flu: 'yes' + }, + group_select_vaccines: { + g_vaccines: 'flu' + }, + group_flu: { + g_flu: 'yes' + }, + group_note: { + default_chw_sms: 'default', + default_chw_sms_text: `Nice work`, + is_sms_edited: 'yes' + }, + group_review: { + r_patient_id: patient.patient_id + }, + meta: { + instanceID: `uuid:${uuidv4()}` + } + }, + geolocation_log: [ + { + timestamp: Date.now(), + recording: { + code: 1, + message: 'User denied Geolocation' + } + } + ], + geolocation: { + code: 1, + message: 'User denied Geolocation' + } + }) +}; + +module.exports = immunizationVisitFactory; diff --git a/tests/integration/.mocharc-base.js b/tests/integration/.mocharc-base.js index 99223dd2f61..068a4b04fb7 100644 --- a/tests/integration/.mocharc-base.js +++ b/tests/integration/.mocharc-base.js @@ -7,7 +7,9 @@ const deepEqualInAnyOrder = require('deep-equal-in-any-order'); chai.use(chaiExclude); chai.use(chaiAsPromised); chai.use(deepEqualInAnyOrder); +chai.use(require('chai-shallow-deep-equal')); global.expect = chai.expect; +global.chai = chai; module.exports = { allowUncaught: false, diff --git a/tests/integration/api/controllers/all-docs.spec.js b/tests/integration/api/controllers/all-docs.spec.js index f67f88b71cd..3df2c65bbd8 100644 --- a/tests/integration/api/controllers/all-docs.spec.js +++ b/tests/integration/api/controllers/all-docs.spec.js @@ -206,7 +206,6 @@ describe('all_docs handler', () => { const request = { method: 'POST', body: { keys }, - headers: { 'Content-Type': 'application/json' } }; return utils diff --git a/tests/integration/api/controllers/bulk-docs.spec.js b/tests/integration/api/controllers/bulk-docs.spec.js index fa3b4376054..10cf7bb7fc6 100644 --- a/tests/integration/api/controllers/bulk-docs.spec.js +++ b/tests/integration/api/controllers/bulk-docs.spec.js @@ -84,7 +84,7 @@ describe('bulk-docs handler', () => { before(async () => { await utils.saveDoc(parentPlace); await sUtils.waitForSentinel(); - await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], true); + await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], { ignoreReload: true }); await utils.createUsers(users); }); @@ -286,13 +286,13 @@ describe('bulk-docs handler', () => { }).then(result => { chai.expect(result.length).to.equal(8); chai.expect(result[0]).excluding('_rev').to.deep.equal(docs[0]); - chai.expect(result[1]).to.deep.nested.include({ 'responseBody.error': 'not_found' }); + chai.expect(result[1]).to.deep.nested.include({ 'body.error': 'not_found' }); chai.expect(result[2]).excluding('_rev').to.deep.equal(existentDocs[2]); chai.expect(result[3]).excluding('_rev').to.deep.equal(existentDocs[3]); chai.expect(result[4]).excluding('_rev').to.deep.equal(existentDocs[0]); chai.expect(result[5]).excluding('_rev').to.deep.equal(docs[5]); - chai.expect(result[6]).to.deep.nested.include({ 'responseBody.error': 'not_found' }); + chai.expect(result[6]).to.deep.nested.include({ 'body.error': 'not_found' }); chai.expect(result[7]).excluding( ['_rev', '_id']).to.deep.equal(docs[7]); return sUtils.waitForSentinel(ids).then(() => sUtils.getInfoDocs(ids)); @@ -757,12 +757,12 @@ describe('bulk-docs handler', () => { chai.expect(results.length).to.equal(8); chai.expect(results[0]).excluding('_rev').to.deep.equal(docs[0]); - chai.expect(results[1]).to.deep.nested.include({ 'responseBody.error': 'not_found' }); + chai.expect(results[1]).to.deep.nested.include({ 'body.error': 'not_found' }); chai.expect(results[2]).excluding('_rev').to.deep.equal(existentDocs[2]); chai.expect(results[3]).excluding('_rev').to.deep.equal(existentDocs[3]); chai.expect(results[4]).excluding('_rev').to.deep.equal(existentDocs[0]); chai.expect(results[5]).excluding('_rev').to.deep.equal(docs[5]); - chai.expect(results[6]).to.deep.nested.include({ 'responseBody.error': 'not_found' }); + chai.expect(results[6]).to.deep.nested.include({ 'body.error': 'not_found' }); chai.expect(results[7]).excluding(['_rev', '_id']).to.deep.equal(docs[7]); }); }); @@ -804,7 +804,7 @@ describe('bulk-docs handler', () => { chai.expect(result[0]).to.include({ id: 'denied_report', error: 'forbidden' }); } else { // CouchDB interprets this as an attachment POST request - chai.expect(result).to.deep.nested.include({ 'responseBody.error': 'method_not_allowed' }); + chai.expect(result).to.deep.nested.include({ 'body.error': 'method_not_allowed' }); } }); }); @@ -862,9 +862,9 @@ describe('bulk-docs handler', () => { }) .then(results => { chai.expect(results[0]).to.deep.equal(docs[0]); - chai.expect(results[1]).to.include({ statusCode: 404 }); + chai.expect(results[1]).to.include({ status: 404 }); chai.expect(results[2]).to.deep.equal(docs[2]); - chai.expect(results[3]).to.include({ statusCode: 404 }); + chai.expect(results[3]).to.include({ status: 404 }); }); }); diff --git a/tests/integration/api/controllers/bulk-get.spec.js b/tests/integration/api/controllers/bulk-get.spec.js index cc23e31f647..d8d60701217 100644 --- a/tests/integration/api/controllers/bulk-get.spec.js +++ b/tests/integration/api/controllers/bulk-get.spec.js @@ -504,7 +504,7 @@ describe('bulk-get handler', () => { if (result.results) { chai.expect(result.results.length).to.equal(0); } else { - chai.expect(result.responseBody).to.equal('Server error'); + chai.expect(result.body).to.equal('Server error'); } }); }); diff --git a/tests/integration/api/controllers/changes.spec.js b/tests/integration/api/controllers/changes.spec.js index 9daab7da414..1c6d2791c32 100644 --- a/tests/integration/api/controllers/changes.spec.js +++ b/tests/integration/api/controllers/changes.spec.js @@ -214,8 +214,8 @@ describe('changes handler', () => { }) .then(response => { expect(response.headers).to.be.ok; - expect(response.headers['content-type']).to.equal('application/json'); - expect(response.headers.server).to.be.ok; + expect(response.headers.get('content-type')).to.equal('application/json'); + expect(response.headers.get('server')).to.be.ok; }); }); }); @@ -316,7 +316,7 @@ describe('changes handler', () => { if (result.results) { return assertChangeIds(result, ...changesIDs, bobUserId); } - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.body.error).to.equal('forbidden'); }); }); }); diff --git a/tests/integration/api/controllers/contacts-by-phone.spec.js b/tests/integration/api/controllers/contacts-by-phone.spec.js index 286f3c9e616..3ead3ec99c3 100644 --- a/tests/integration/api/controllers/contacts-by-phone.spec.js +++ b/tests/integration/api/controllers/contacts-by-phone.spec.js @@ -185,8 +185,8 @@ describe('Contacts by phone API', () => { .request(noAuthRequestOptions) .then(() => chai.assert.fail('Should not allow unauthenticated requests')) .catch(err => { - chai.expect(err.statusCode).to.equal(401); - chai.expect(err.error).to.deep.include({ code: 401, error: 'unauthorized' }); + chai.expect(err.status).to.equal(401); + chai.expect(err.body).to.deep.include({ code: 401, error: 'unauthorized' }); }); }); @@ -197,8 +197,8 @@ describe('Contacts by phone API', () => { .request(noAuthRequestOptions) .then(() => chai.assert.fail('Should not allow unauthenticated requests')) .catch(err => { - chai.expect(err.statusCode).to.equal(401); - chai.expect(err.error).to.deep.include({ code: 401, error: 'unauthorized' }); + chai.expect(err.status).to.equal(401); + chai.expect(err.body).to.deep.include({ code: 401, error: 'unauthorized' }); }); }); }); @@ -210,8 +210,8 @@ describe('Contacts by phone API', () => { .request(offlineRequestOptions) .then(() => chai.assert.fail('Should not allow offline users')) .catch(err => { - chai.expect(err.statusCode).to.equal(403); - chai.expect(err.error).to.deep.include({ code: 403, error: 'forbidden' }); + chai.expect(err.status).to.equal(403); + chai.expect(err.body).to.deep.include({ code: 403, error: 'forbidden' }); }); }); @@ -222,8 +222,8 @@ describe('Contacts by phone API', () => { .request(offlineRequestOptions) .then(() => chai.assert.fail('Should not allow offline users')) .catch(err => { - chai.expect(err.statusCode).to.equal(403); - chai.expect(err.error).to.deep.include({ code: 403, error: 'forbidden' }); + chai.expect(err.status).to.equal(403); + chai.expect(err.body).to.deep.include({ code: 403, error: 'forbidden' }); }); }); }); @@ -234,8 +234,8 @@ describe('Contacts by phone API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`phone` parameter is required and must be a valid phone number' }); @@ -248,8 +248,8 @@ describe('Contacts by phone API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`phone` parameter is required and must be a valid phone number' }); @@ -284,7 +284,7 @@ describe('Contacts by phone API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should 404 when not found')) .catch(result => { - chai.expect(result.error).to.deep.equal({ error: 'not_found', reason: 'no matches found' }); + chai.expect(result.body).to.deep.equal({ error: 'not_found', reason: 'no matches found' }); }); }); @@ -309,8 +309,8 @@ describe('Contacts by phone API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`phone` parameter is required and must be a valid phone number' }); @@ -323,8 +323,8 @@ describe('Contacts by phone API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail with incorrect params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`phone` parameter is required and must be a valid phone number' }); @@ -369,7 +369,7 @@ describe('Contacts by phone API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should 404 when not found')) .catch(result => { - chai.expect(result.error).to.deep.equal({ error: 'not_found', reason: 'no matches found' }); + chai.expect(result.body).to.deep.equal({ error: 'not_found', reason: 'no matches found' }); }); }); diff --git a/tests/integration/api/controllers/db-doc.spec.js b/tests/integration/api/controllers/db-doc.spec.js index 45b042fd03e..28bb8bca911 100644 --- a/tests/integration/api/controllers/db-doc.spec.js +++ b/tests/integration/api/controllers/db-doc.spec.js @@ -273,7 +273,7 @@ describe('db-doc handler', () => { return utils.getDoc('db_doc_delete'); }) .catch(err => { - chai.expect(err.responseBody.error).to.equal('not_found'); + chai.expect(err.body.error).to.equal('not_found'); }); }); @@ -344,10 +344,10 @@ describe('db-doc handler', () => { chai.expect(results[3]) .to.deep.include(patients.find(patient => patient._id === 'fixture:offline:clinic:patient')); - chai.expect(results[4]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[5]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[6]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[7]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[4]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[5]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[6]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[7]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -422,7 +422,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -456,7 +456,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -496,7 +496,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -655,7 +655,7 @@ describe('db-doc handler', () => { if (patientsToDelete[idx]._id.startsWith('temp:offline')) { chai.expect(result).to.deep.include(patientsToDelete[idx]); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }) @@ -668,7 +668,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc, idx); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}, idx); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}, idx); } }); }) @@ -683,7 +683,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc, idx); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}, idx); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}, idx); } }); }) @@ -700,7 +700,7 @@ describe('db-doc handler', () => { chai.expect(result).to.deep.include(reportScenarios[idx].doc); chai.expect(result._deleted).to.equal(true); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -776,7 +776,7 @@ describe('db-doc handler', () => { .then(results => { // cannot read patients results.forEach(result => { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }) .then(() => Promise.all(reportScenarios.map(scenario => utils.requestOnTestDb( @@ -788,7 +788,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }) @@ -801,7 +801,7 @@ describe('db-doc handler', () => { .then(results => { // cannot read deleted patients results.forEach(result => { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }) .then(() => Promise.all(reportScenarios.map(scenario => utils.requestOnTestDb( @@ -813,7 +813,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}, idx); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}, idx); } }); }) @@ -828,7 +828,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include(reportScenarios[idx].doc); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }) @@ -845,7 +845,7 @@ describe('db-doc handler', () => { chai.expect(result).to.deep.include(reportScenarios[idx].doc); chai.expect(result._deleted).to.equal(true); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1024,9 +1024,9 @@ describe('db-doc handler', () => { chai.expect(results[2]._revs_info.length).to.deep.equal(results[2]._revisions.ids.length); chai.expect(results[2]._revs_info[0]).to.deep.equal({ rev: revs.allowed_attach[1], status: 'available' }); - chai.expect(results[3].statusCode).to.deep.equal(403); - chai.expect(results[4].statusCode).to.deep.equal(403); - chai.expect(results[5].statusCode).to.deep.equal(403); + chai.expect(results[3].status).to.deep.equal(403); + chai.expect(results[4].status).to.deep.equal(403); + chai.expect(results[5].status).to.deep.equal(403); }); }); @@ -1076,9 +1076,9 @@ describe('db-doc handler', () => { ])) .then(results => { chai.expect(results[0]).to.deep.include(allowedTask); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); chai.expect(results[2]).to.deep.include(allowedTarget); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }) .then(() => Promise.all([ utils.requestOnTestDb(_.defaults({ path: '/fixture:user:offline' }, supervisorRequestOptions)), @@ -1097,13 +1097,13 @@ describe('db-doc handler', () => { // supervisor can see the user's contact chai.expect(results[0]._id).to.equal('fixture:user:offline'); // supervisor can't see the user's user-settings document - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); // supervisor has replication depth of 2 - chai.expect(results[2]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[2]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); // supervisor can't see the any user's tasks - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[4]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[4]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); // supervisor can see both targets chai.expect(results[5]).to.deep.include(allowedTarget); @@ -1137,7 +1137,7 @@ describe('db-doc handler', () => { .then(() => utils.requestOnTestDb(_.defaults({ path: `/${doc._id}` }, offlineRequestOptions)).catch(err => err)) .then(result => { // user can't see the unallocated report without permissions - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }) .then(() => utils.updateSettings(settings, { ignoreReload: true })) .then(() => utils.requestOnTestDb(_.defaults({ path: `/${doc._id}` }, offlineRequestOptions)).catch(err => err)) @@ -1253,7 +1253,7 @@ describe('db-doc handler', () => { chai.expect(result).excluding('_rev').to.deep.equal(originalDoc); } else { // a private report, expect an error - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1282,8 +1282,8 @@ describe('db-doc handler', () => { ]) .then(([allowed, denied, forbidden]) => { chai.expect(allowed).to.include({ id: 'allowed_doc_post', ok: true, }); - chai.expect(denied).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(forbidden).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(denied).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(forbidden).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); return Promise.all([ utils.getDoc('allowed_doc_post'), @@ -1292,7 +1292,7 @@ describe('db-doc handler', () => { }) .then(([allowed, denied]) => { chai.expect(allowed).to.deep.include(allowedDoc); - chai.expect(denied.statusCode).to.deep.equal(404); + chai.expect(denied.status).to.deep.equal(404); const ids = ['allowed_doc_post', 'denied_doc_post']; return sentinelUtils.waitForSentinel(ids).then(() => sentinelUtils.getInfoDocs(ids)); @@ -1385,7 +1385,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include({ ok: true, id: reportScenarios[idx].doc._id }); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1419,7 +1419,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include({ ok: true, id: reportScenarios[idx].doc._id }); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1469,9 +1469,9 @@ describe('db-doc handler', () => { ]) .then(results => { chai.expect(results[0]).to.deep.include({ ok: true, id: 'task1' }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); chai.expect(results[2]).to.deep.include({ ok: true, id: 'target1' }); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -1534,12 +1534,12 @@ describe('db-doc handler', () => { }) .then(results => { chai.expect(results[0]).to.include({ ok: true, id: 'n_put_1' }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); chai.expect(results[2]).to.include({ ok: true, id: 'a_put_1', }); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[4]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[5]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[4]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[5]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); const ids = ['a_put_1', 'a_put_2', 'd_put_1', 'd_put_2', 'n_put_1', 'n_put_2']; @@ -1607,10 +1607,10 @@ describe('db-doc handler', () => { .catch(err => err))); }) .then(results => { - chai.expect(results[0]).to.deep.nested.include({ statusCode: 409, 'responseBody.error': 'conflict'}); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[2]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 409, 'responseBody.error': 'conflict'}); + chai.expect(results[0]).to.deep.nested.include({ status: 409, 'body.error': 'conflict'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[2]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 409, 'body.error': 'conflict'}); }); }); @@ -1685,7 +1685,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include({ ok: true, id: reportScenarios[idx].doc._id }); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1719,7 +1719,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include({ ok: true, id: reportScenarios[idx].doc._id }); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1758,7 +1758,7 @@ describe('db-doc handler', () => { if (reportScenarios[idx].allowed) { chai.expect(result).to.deep.include({ ok: true, id: reportScenarios[idx].doc._id }); } else { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); } }); }); @@ -1812,9 +1812,9 @@ describe('db-doc handler', () => { }) .then(results => { chai.expect(results[0]).to.deep.include({ ok: true, id: 'task1' }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); chai.expect(results[2]).to.deep.include({ ok: true, id: 'target1' }); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -1843,7 +1843,7 @@ describe('db-doc handler', () => { ])) .then(results => { chai.expect(results[0]).to.deep.include({ id: 'allowed_del', ok: true }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); return Promise.all([ utils.getDoc('allowed_del').catch(err => err), @@ -1852,7 +1852,7 @@ describe('db-doc handler', () => { }) .then(results => { chai.expect(results[0]).to.deep.include({ - statusCode: 404, responseBody: { error: 'not_found', reason: 'deleted' } + status: 404, body: { error: 'not_found', reason: 'deleted' } }); chai.expect(results[1]).to.deep.include({ _id: 'denied_del', @@ -1915,10 +1915,10 @@ describe('db-doc handler', () => { .then(results => { chai.expect(results[0]).to.equal('my attachment content'); chai.expect(results[1]).to.deep.include( - { statusCode: 404, responseBody: { error: 'bad_request', reason: 'Invalid rev format' }} + { status: 404, body: { error: 'bad_request', reason: 'Invalid rev format' }} ); chai.expect(results[2]).to.deep.include( - { statusCode: 403, responseBody: { error: 'forbidden', reason: 'Insufficient privileges' }} + { status: 403, body: { error: 'forbidden', reason: 'Insufficient privileges' }} ); return Promise.all([ @@ -1945,19 +1945,19 @@ describe('db-doc handler', () => { }) .then(results => { // allowed_attach is allowed, but missing attachment - chai.expect(results[0].responseBody).to.deep.equal({ + chai.expect(results[0].body).to.deep.equal({ error: 'not_found', reason: 'Document is missing attachment', }); // allowed_attach is allowed and has attachment chai.expect(results[1]).to.equal('my attachment content'); // allowed_attach is not allowed and has attachment - chai.expect(results[2].responseBody.error).to.equal('forbidden'); + chai.expect(results[2].body.error).to.equal('forbidden'); // denied_attach is not allowed, but missing attachment - chai.expect(results[3].responseBody.error).to.equal('forbidden'); + chai.expect(results[3].body.error).to.equal('forbidden'); // denied_attach is not allowed and has attachment - chai.expect(results[4].responseBody.error).to.equal('forbidden'); + chai.expect(results[4].body.error).to.equal('forbidden'); // denied_attach is allowed and has attachment chai.expect(results[5]).to.equal('my attachment content'); @@ -1987,9 +1987,9 @@ describe('db-doc handler', () => { ]); }) .then(results => { - chai.expect(results[0]).to.deep.nested.include({ statusCode: 404, 'responseBody.error': 'bad_request' }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[2]).to.deep.nested.include({ statusCode: 404, 'responseBody.error': 'bad_request' }); + chai.expect(results[0]).to.deep.nested.include({ status: 404, 'body.error': 'bad_request' }); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[2]).to.deep.nested.include({ status: 404, 'body.error': 'bad_request' }); chai.expect(results[3]).to.equal('my attachment content'); }); }); @@ -2050,8 +2050,8 @@ describe('db-doc handler', () => { chai.expect(results[0]).to.equal('my attachment content'); chai.expect(results[1]).to.equal('my attachment content'); - chai.expect(results[2]).to.deep.nested.include({ statusCode: 404, 'responseBody.error': 'bad_request' }); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[2]).to.deep.nested.include({ status: 404, 'body.error': 'bad_request' }); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); return Promise.all([ utils.getDoc('allowed_attach_1'), @@ -2078,18 +2078,18 @@ describe('db-doc handler', () => { }) .then(results => { // allowed_attach is allowed, but missing attachment - chai.expect(results[0].responseBody).to.deep.equal({ + chai.expect(results[0].body).to.deep.equal({ error: 'not_found', reason: 'Document is missing attachment', }); // allowed_attach is allowed and has attachment chai.expect(results[1]).to.equal('my attachment content'); // allowed_attach is not allowed and has attachment - chai.expect(results[2].responseBody.error).to.equal('forbidden'); + chai.expect(results[2].body.error).to.equal('forbidden'); // denied_attach is not allowed, but missing attachment - chai.expect(results[3].responseBody.error).to.equal('forbidden'); + chai.expect(results[3].body.error).to.equal('forbidden'); // denied_attach is not allowed and has attachment - chai.expect(results[4].responseBody.error).to.equal('forbidden'); + chai.expect(results[4].body.error).to.equal('forbidden'); // denied_attach is allowed and has attachment chai.expect(results[5]).to.equal('my attachment content'); }); @@ -2127,7 +2127,7 @@ describe('db-doc handler', () => { )) .then(results => { chai.expect(results[0]).to.deep.include({ ok: true, id: 'a_with_attachments' }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); return Promise.all([ utils.requestOnTestDb({ path: '/a_with_attachments' }), @@ -2145,7 +2145,7 @@ describe('db-doc handler', () => { chai.expect(results[2]._attachments).to.be.undefined; chai.expect(results[2]._id).to.equal('d_with_attachments'); - chai.expect(results[3].responseBody.error).to.equal('not_found'); + chai.expect(results[3].body.error).to.equal('not_found'); }); }); }); @@ -2164,7 +2164,7 @@ describe('db-doc handler', () => { type: 'person', parent: { _id: 'fixture:offline', parent: { _id: 'PARENT_PLACE' } }, }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -2218,11 +2218,11 @@ describe('db-doc handler', () => { }) .then(results => { chai.expect(results[0]).to.deep.include({ ok: true, id: 'n_put_1' }); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); chai.expect(results[2]).to.deep.include({ ok: true, id: 'a_put_1' }); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[4]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[5]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[4]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[5]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -2306,7 +2306,7 @@ describe('db-doc handler', () => { .catch(err => err), ])) .then(results => { - chai.expect(results.every(result => result.statusCode === 403 || result.statusCode === 404)).to.equal(true); + chai.expect(results.every(result => result.status === 403 || result.status === 404)).to.equal(true); }); }); @@ -2347,7 +2347,7 @@ describe('db-doc handler', () => { return utils.requestOnTestDb(offlineRequestOptions).catch(err => err); }) .then(result => { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); return utils.getDoc('fb1'); }) .then(result => { @@ -2368,9 +2368,9 @@ describe('db-doc handler', () => { .then(results => { chai.expect(results[0]._id).to.equal('_design/medic-client'); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[2]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[2]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -2392,10 +2392,10 @@ describe('db-doc handler', () => { .catch(err => err), ]) .then(results => { - chai.expect(results[0]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[1]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[2]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); - chai.expect(results[3]).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(results[0]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[1]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[2]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); + chai.expect(results[3]).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); @@ -2425,7 +2425,7 @@ describe('db-doc handler', () => { ]) .then(results => { results.forEach(result => { - chai.expect(result).to.deep.nested.include({ statusCode: 403, 'responseBody.error': 'forbidden'}); + chai.expect(result).to.deep.nested.include({ status: 403, 'body.error': 'forbidden'}); }); }); }); diff --git a/tests/integration/api/controllers/hydration.spec.js b/tests/integration/api/controllers/hydration.spec.js index df74fee36da..a32e9668a40 100644 --- a/tests/integration/api/controllers/hydration.spec.js +++ b/tests/integration/api/controllers/hydration.spec.js @@ -269,8 +269,8 @@ describe('Hydration API', () => { .request(noAuthRequestOptions) .then(() => chai.assert.fail('Should not allow unauthenticated requests')) .catch(err => { - chai.expect(err.statusCode).to.equal(401); - chai.expect(err.error).to.deep.include({ code: 401, error: 'unauthorized' }); + chai.expect(err.status).to.equal(401); + chai.expect(err.body).to.deep.include({ code: 401, error: 'unauthorized' }); }); }); @@ -281,8 +281,8 @@ describe('Hydration API', () => { .request(noAuthRequestOptions) .then(() => chai.assert.fail('Should not allow unauthenticated requests')) .catch(err => { - chai.expect(err.statusCode).to.equal(401); - chai.expect(err.error).to.deep.include({ code: 401, error: 'unauthorized' }); + chai.expect(err.status).to.equal(401); + chai.expect(err.body).to.deep.include({ code: 401, error: 'unauthorized' }); }); }); }); @@ -294,8 +294,8 @@ describe('Hydration API', () => { .request(offlineRequestOptions) .then(() => chai.assert.fail('Should not allow offline users')) .catch(err => { - chai.expect(err.statusCode).to.equal(403); - chai.expect(err.error).to.deep.include({ code: 403, error: 'forbidden' }); + chai.expect(err.status).to.equal(403); + chai.expect(err.body).to.deep.include({ code: 403, error: 'forbidden' }); }); }); @@ -306,8 +306,8 @@ describe('Hydration API', () => { .request(offlineRequestOptions) .then(() => chai.assert.fail('Should not allow offline users')) .catch(err => { - chai.expect(err.statusCode).to.equal(403); - chai.expect(err.error).to.deep.include({ code: 403, error: 'forbidden' }); + chai.expect(err.status).to.equal(403); + chai.expect(err.body).to.deep.include({ code: 403, error: 'forbidden' }); }); }); }); @@ -318,8 +318,8 @@ describe('Hydration API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`doc_ids` parameter must be a json array.' }); @@ -332,8 +332,8 @@ describe('Hydration API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`doc_ids` parameter must be a json array.' }); @@ -480,8 +480,8 @@ describe('Hydration API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`doc_ids` parameter must be a json array.' }); @@ -494,8 +494,8 @@ describe('Hydration API', () => { .request(onlineRequestOptions) .then(() => chai.assert.fail('Should fail when no params')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); - chai.expect(err.error).to.deep.equal({ + chai.expect(err.status).to.equal(400); + chai.expect(err.body).to.deep.equal({ error: 'bad_request', reason: '`doc_ids` parameter must be a json array.' }); diff --git a/tests/integration/api/controllers/login.spec.js b/tests/integration/api/controllers/login.spec.js index dc9be54a07e..c14a19c86be 100644 --- a/tests/integration/api/controllers/login.spec.js +++ b/tests/integration/api/controllers/login.spec.js @@ -20,10 +20,10 @@ const loginWithData = data => { const opts = { path: '/medic/login?aaa=aaa', method: 'POST', - simple: false, + resolveWithFullResponse: true, noAuth: true, body: data, - followRedirect: false, + redirect: 'manual', headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts); @@ -33,10 +33,9 @@ const loginWithTokenLink = (token = '') => { const opts = { path: `/medic/login/token/${token}`, method: 'POST', - simple: false, resolveWithFullResponse: true, noAuth: true, - followRedirect: false, + redirect: 'manual', body: {}, headers: { 'X-Forwarded-For': randomIp() }, }; @@ -44,16 +43,16 @@ const loginWithTokenLink = (token = '') => { }; const expectLoginToWork = (response) => { - chai.expect(response).to.include({ statusCode: 302 }); - chai.expect(response.headers['set-cookie']).to.be.an('array'); - chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; - chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + chai.expect(response).to.include({ status: 302 }); + chai.expect(response.headers.getSetCookie()).to.be.an('array'); + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('userCtx'))).to.be.ok; chai.expect(response.body).to.equal('/'); }; const expectLoginToFail = (response) => { - chai.expect(response.headers['set-cookie']).to.be.undefined; - chai.expect(response.statusCode).to.equal(401); + chai.expect(response.headers.getSetCookie()).to.deep.equal([]); + chai.expect(response.status).to.equal(401); }; const getUser = (user) => { @@ -135,7 +134,7 @@ describe('login', () => { it('should fail with invalid url', () => { return setupTokenLoginSettings() .then(() => loginWithTokenLink()) - .then(response => chai.expect(response).to.deep.include({ statusCode: 401 })); + .then(response => chai.expect(response).to.deep.include({ status: 401 })); }); it('should fail with invalid data', () => { diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js index d35b93d179d..fa21dc6f22e 100644 --- a/tests/integration/api/controllers/person.spec.js +++ b/tests/integration/api/controllers/person.spec.js @@ -1,8 +1,6 @@ const utils = require('@utils'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); -const { getRemoteDataContext, Person, Qualifier } = require('@medic/cht-datasource'); -const { expect } = require('chai'); const userFactory = require('@factories/cht/users/users'); describe('Person API', () => { @@ -47,7 +45,6 @@ describe('Person API', () => { roles: ['chw'] })); const allDocItems = [contact0, contact1, contact2, place0, place1, place2, patient]; - const dataContext = getRemoteDataContext(utils.getOrigin()); const personType = 'person'; const e2eTestUser = { '_id': 'e2e_contact_test_id', @@ -96,16 +93,13 @@ describe('Person API', () => { }); describe('GET /api/v1/person/:uuid', async () => { - const getPerson = Person.v1.get(dataContext); - const getPersonWithLineage = Person.v1.getWithLineage(dataContext); - it('returns the person matching the provided UUID', async () => { - const person = await getPerson(Qualifier.byUuid(patient._id)); + const person = await utils.request(`/api/v1/person/${patient._id}`); expect(person).excluding(['_rev', 'reported_date']).to.deep.equal(patient); }); it('returns the person with lineage when the withLineage query parameter is provided', async () => { - const person = await getPersonWithLineage(Qualifier.byUuid(patient._id)); + const person = await utils.request({ path: `/api/v1/person/${patient._id}`, qs: { with_lineage: true } }); expect(person).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ ...patient, parent: { @@ -124,8 +118,8 @@ describe('Person API', () => { }); it('returns null when no person is found for the UUID', async () => { - const person = await getPerson(Qualifier.byUuid('invalid-uuid')); - expect(person).to.be.null; + await expect(utils.request('/api/v1/person/invalid-uuid')) + .to.be.rejectedWith('404 - {"code":404,"error":"Person not found"}'); }); [ @@ -143,13 +137,11 @@ describe('Person API', () => { }); describe('GET /api/v1/person', async () => { - const getPage = Person.v1.getPage(dataContext); const limit = 4; - const cursor = null; const invalidContactType = 'invalidPerson'; it('returns a page of people for no limit and cursor passed', async () => { - const responsePage = await getPage(Qualifier.byContactType(personType)); + const responsePage = await utils.request({ path: `/api/v1/person`, qs: { type: personType } }); const responsePeople = responsePage.data; const responseCursor = responsePage.cursor; @@ -158,8 +150,11 @@ describe('Person API', () => { }); it('returns a page of people when limit and cursor is passed and cursor can be reused', async () => { - const firstPage = await getPage(Qualifier.byContactType(personType), cursor, limit); - const secondPage = await getPage(Qualifier.byContactType(personType), firstPage.cursor, limit); + const firstPage = await utils.request({ path: `/api/v1/person`, qs: { type: personType, limit } }); + const secondPage = await utils.request({ + path: `/api/v1/person`, + qs: { type: personType, cursor: firstPage.cursor, limit } + }); const allPeople = [...firstPage.data, ...secondPage.data]; @@ -230,17 +225,18 @@ describe('Person API', () => { }); }); - describe('Person.v1.getAll', async () => { - it('fetches all data by iterating through generator', async () => { - const docs = []; - - const generator = Person.v1.getAll(dataContext)(Qualifier.byContactType(personType)); - - for await (const doc of generator) { - docs.push(doc); - } - - expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeople); - }); - }); + // todo rethink this once datasource works with authentication #9701 + // describe('Person.v1.getAll', async () => { + // it('fetches all data by iterating through generator', async () => { + // const docs = []; + // + // const generator = Person.v1.getAll(dataContext)(Qualifier.byContactType(personType)); + // + // for await (const doc of generator) { + // docs.push(doc); + // } + // + // expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeople); + // }); + // }); }); diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js index d80ed338461..962144d403f 100644 --- a/tests/integration/api/controllers/place.spec.js +++ b/tests/integration/api/controllers/place.spec.js @@ -1,8 +1,6 @@ const utils = require('@utils'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); -const { getRemoteDataContext, Place, Qualifier } = require('@medic/cht-datasource'); -const { expect } = require('chai'); const userFactory = require('@factories/cht/users/users'); describe('Place API', () => { @@ -62,7 +60,6 @@ describe('Place API', () => { }, roles: ['chw'] })); - const dataContext = getRemoteDataContext(utils.getOrigin()); const expectedPlaces = [place0, clinic1, clinic2]; before(async () => { @@ -76,16 +73,13 @@ describe('Place API', () => { }); describe('GET /api/v1/place/:uuid', async () => { - const getPlace = Place.v1.get(dataContext); - const getPlaceWithLineage = Place.v1.getWithLineage(dataContext); - it('returns the place matching the provided UUID', async () => { - const place = await getPlace(Qualifier.byUuid(place0._id)); + const place = await utils.request(`/api/v1/place/${place0._id}`); expect(place).excluding(['_rev', 'reported_date']).to.deep.equal(place0); }); it('returns the place with lineage when the withLineage query parameter is provided', async () => { - const place = await getPlaceWithLineage(Qualifier.byUuid(place0._id)); + const place = await utils.request({ path: `/api/v1/place/${place0._id}`, qs: { with_lineage: true } }); expect(place).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ ...place0, contact: contact0, @@ -101,8 +95,8 @@ describe('Place API', () => { }); it('returns null when no place is found for the UUID', async () => { - const place = await getPlace(Qualifier.byUuid('invalid-uuid')); - expect(place).to.be.null; + await expect(utils.request('/api/v1/place/invalid-uuid')) + .to.be.rejectedWith('404 - {"code":404,"error":"Place not found"}'); }); [ @@ -120,13 +114,11 @@ describe('Place API', () => { }); describe('GET /api/v1/place', async () => { - const getPage = Place.v1.getPage(dataContext); const limit = 2; - const cursor = null; const invalidContactType = 'invalidPlace'; it('returns a page of places for no limit and cursor passed', async () => { - const responsePage = await getPage(Qualifier.byContactType(placeType)); + const responsePage = await utils.request({ path: `/api/v1/place`, qs: { type: placeType } }); const responsePlaces = responsePage.data; const responseCursor = responsePage.cursor; @@ -136,8 +128,11 @@ describe('Place API', () => { }); it('returns a page of places when limit and cursor is passed and cursor can be reused', async () => { - const firstPage = await getPage(Qualifier.byContactType(placeType), cursor, limit); - const secondPage = await getPage(Qualifier.byContactType(placeType), firstPage.cursor, limit); + const firstPage = await utils.request({ path: `/api/v1/place`, qs: { type: placeType, limit } }); + const secondPage = await utils.request({ + path: `/api/v1/place`, + qs: { type: placeType, cursor: firstPage.cursor, limit } + }); const allPeople = [...firstPage.data, ...secondPage.data]; @@ -208,17 +203,18 @@ describe('Place API', () => { }); }); - describe('Place.v1.getAll', async () => { - it('fetches all data by iterating through generator', async () => { - const docs = []; - - const generator = Place.v1.getAll(dataContext)(Qualifier.byContactType(placeType)); - - for await (const doc of generator) { - docs.push(doc); - } - - expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPlaces); - }); - }); + // todo rethink this once datasource works with authentication #9701 + // describe('Place.v1.getAll', async () => { + // it('fetches all data by iterating through generator', async () => { + // const docs = []; + // + // const generator = Place.v1.getAll(dataContext)(Qualifier.byContactType(placeType)); + // + // for await (const doc of generator) { + // docs.push(doc); + // } + // + // expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPlaces); + // }); + // }); }); diff --git a/tests/integration/api/controllers/places.spec.js b/tests/integration/api/controllers/places.spec.js index 3d0862b4ef6..d607da09363 100644 --- a/tests/integration/api/controllers/places.spec.js +++ b/tests/integration/api/controllers/places.spec.js @@ -205,7 +205,7 @@ describe('Places API', () => { return utils.request(onlineRequestOptions) .then(() => fail('Call should fail as contact type is not a person')) .catch(err => { - chai.expect(err.responseBody.error).to.equal('Wrong type, this is not a person.'); + chai.expect(err.body.error).to.equal('Wrong type, this is not a person.'); }); }); @@ -220,7 +220,7 @@ describe('Places API', () => { return utils.request(onlineRequestOptions) .then(() => fail('Call should fail as contact does not exist')) .catch(err => { - chai.expect(err.responseBody.error).to.equal('Failed to find person.'); + chai.expect(err.body.error).to.equal('Failed to find person.'); }); }); diff --git a/tests/integration/api/controllers/records.spec.js b/tests/integration/api/controllers/records.spec.js index 97c2434cee5..bcea32cfd9a 100644 --- a/tests/integration/api/controllers/records.spec.js +++ b/tests/integration/api/controllers/records.spec.js @@ -46,9 +46,6 @@ describe('Import Records', () => { .then(() => utils.request({ method: 'POST', path: '/api/v2/records', - headers: { - 'Content-type': 'application/json' - }, body: { _meta: { form: 'TEST', @@ -94,9 +91,6 @@ describe('Import Records', () => { .then(() => utils.request({ method: 'POST', path: '/api/v2/records', - headers: { - 'Content-type': 'application/json' - }, body: { _meta: { form: 'TEST' @@ -134,9 +128,6 @@ describe('Import Records', () => { .then(() => utils.request({ method: 'POST', path: '/api/v2/records', - headers: { - 'Content-type': 'application/json' - }, body: { _meta: { form: 'TEST', diff --git a/tests/integration/api/controllers/replication.spec.js b/tests/integration/api/controllers/replication.spec.js index 4213d4cf6cd..77a74fbda20 100644 --- a/tests/integration/api/controllers/replication.spec.js +++ b/tests/integration/api/controllers/replication.spec.js @@ -193,7 +193,7 @@ describe('replication', () => { ]; before(async () => { - await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], true); + await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], { ignoreReload: true }); await utils.saveDoc(parentPlace); await utils.createUsers(users, true); }); @@ -1089,8 +1089,6 @@ describe('replication', () => { { purge: { fn: purgeFn.toString(), text_expression: 'every 1 seconds' } }, { ignoreReload: true } ); - await utils.stopSentinel(); - await utils.startSentinel(); await sentinelUtils.waitForPurgeCompletion(seq); const response = await requestDeletes('bob', getIds(reports)); @@ -1115,8 +1113,6 @@ describe('replication', () => { { purge: { fn: purgeFn.toString(), text_expression: 'every 1 seconds' } }, { ignoreReload: true } ); - await utils.stopSentinel(); - await utils.startSentinel(); await sentinelUtils.waitForPurgeCompletion(seq); const response = await requestDeletes('bob', [...savedIds, ...deletedIds, ...getIds(reports)]); diff --git a/tests/integration/api/controllers/settings.spec.js b/tests/integration/api/controllers/settings.spec.js index c4bda666dc9..643a2ba01a9 100644 --- a/tests/integration/api/controllers/settings.spec.js +++ b/tests/integration/api/controllers/settings.spec.js @@ -29,7 +29,6 @@ describe('Settings API', () => { path: uri, method: 'PUT', body: updates, - headers: { 'Content-Type': 'application/json' } }); }; @@ -124,7 +123,6 @@ describe('Settings API', () => { path: '/api/v1/settings', method: 'PUT', body: updates, - headers: { 'Content-Type': 'application/json' }, qs, }); }; diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js index 68910df19c6..a129526f3ab 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -1,10 +1,6 @@ -const constants = require('@constants'); -const https = require('https'); const utils = require('@utils'); const uuid = require('uuid').v4; const querystring = require('querystring'); -const chai = require('chai'); -chai.use(require('chai-shallow-deep-equal')); const sentinelUtils = require('@utils/sentinel'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); @@ -29,10 +25,10 @@ describe('Users API', () => { const opts = { path: '/login', method: 'POST', - simple: false, + resolveWithFullResponse: true, noAuth: true, body: { user: user.username, password: user.password }, - followRedirect: false, + redirect: 'manual', headers: { 'X-Forwarded-For': randomIp() }, }; @@ -40,12 +36,12 @@ describe('Users API', () => { .requestOnMedicDb(opts) .then(response => { chai.expect(response).to.include({ - statusCode: 302, + status: 302, body: '/', }); - chai.expect(response.headers['set-cookie']).to.be.an('array'); - chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; - chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + chai.expect(response.headers.getSetCookie()).to.be.an('array'); + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('userCtx'))).to.be.ok; }); }; @@ -53,7 +49,7 @@ describe('Users API', () => { const opts = { path: '/login', method: 'POST', - simple: false, + resolveWithFullResponse: true, noAuth: true, body: { user: user.username, password: user.password }, headers: { 'X-Forwarded-For': randomIp() }, @@ -62,7 +58,7 @@ describe('Users API', () => { return utils .requestOnMedicDb(opts) .then(response => { - chai.expect(response).to.deep.include({ statusCode: 401, body: { error: 'Not logged in' } }); + chai.expect(response).to.deep.include({ status: 401, body: { error: 'Not logged in' } }); }); }; @@ -114,66 +110,38 @@ describe('Users API', () => { ]; before(async () => { - await utils.updatePermissions(['chw'], ['can_edit']); + await utils.updatePermissions(['chw'], ['can_edit'], [], { ignoreReload: true }); await utils.request({ path: '/_users', method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, body: _usersUser }); await utils.saveDocs(medicData); - return new Promise((resolve, reject) => { - const options = { - hostname: constants.API_HOST, - path: '/_session', - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - auth: `${username}:${password}` - }; - - // Use http service to extract cookie - const req = https.request(options, res => { - if (res.statusCode !== 200) { - return reject(new Error(`Expected 200 from _session authing, but got ${res.statusCode}`)); - } - - // Example header: - // AuthSession=cm9vdDo1MEJDMDEzRTp7Vu5GKCkTxTVxwXbpXsBARQWnhQ; Version=1; Path=/; HttpOnly - try { - cookie = res.headers['set-cookie'][0].match(/^(AuthSession=[^;]+)/)[0]; - } catch (err) { - return reject(err); - } - - resolve(cookie); - }); - - req.write(JSON.stringify({ + const res = await utils.request({ + path: '/_session', + auth: { username, password }, + method: 'POST', + resolveWithFullResponse: true, + body: { name: username, password: password - })); - req.end(); + } }); + if (!res.ok) { + throw new Error(`Expected 200 from _session authing, but got ${res.status}`); + } + + cookie = res.headers.getSetCookie().find(cookie => cookie.startsWith('AuthSession')); + if (!cookie) { + throw new Error('Expected auth cookie from _session authing'); + } }); after(async () => { - const { _rev } = await utils.request(`/_users/${getUserId(username)}`); - await utils.request({ - path: `/_users/${getUserId(username)}`, - method: 'PUT', - body: { - _id: getUserId(username), - _rev, - _deleted: true, - } - }); + await utils.deleteUsers([{ username }]); await utils.revertSettings(true); await utils.revertDb([], true); }); @@ -214,7 +182,7 @@ describe('Users API', () => { }) .then(() => fail('You should get a 401 in this situation')) .catch(err => { - chai.expect(err.responseBody.error).to.equal('You do not have permissions to modify this person'); + chai.expect(err.body.error).to.equal('You do not have permissions to modify this person'); }); }); @@ -230,7 +198,7 @@ describe('Users API', () => { }) .then(() => fail('You should get an error in this situation')) .catch(err => { - chai.expect(err.responseBody.error).to.equal('unauthorized'); + chai.expect(err.body.error).to.equal('unauthorized'); }); }); @@ -268,7 +236,7 @@ describe('Users API', () => { }) .then(() => fail('You should get an error in this situation')) .catch(err => { - chai.expect(err.responseBody.error).to.equal('You must authenticate with Basic Auth to modify your password'); + chai.expect(err.body.error).to.equal('You must authenticate with Basic Auth to modify your password'); }); }); @@ -516,7 +484,7 @@ describe('Users API', () => { .then(() => chai.expect.fail('should have thrown')) .catch(err => { // online users require the "can_update_users" permission to be able to access this endpoint - chai.expect(err.error).to.deep.equal({ + chai.expect(err.body).to.deep.equal({ code: 403, error: 'Insufficient privileges', }); @@ -535,7 +503,7 @@ describe('Users API', () => { .then(() => chai.expect.fail('should have thrown')) .catch(err => { // online users require the "can_update_users" permission to be able to access this endpoint - chai.expect(err.error).to.deep.equal({ + chai.expect(err.body).to.deep.equal({ code: 403, error: 'Insufficient privileges', }); @@ -580,12 +548,11 @@ describe('Users API', () => { facility_id: 'fixture:offline' }; onlineRequestOptions.path += '?' + querystring.stringify(params); - onlineRequestOptions.headers = { 'Content-Type': 'application/json' }; return utils .request(onlineRequestOptions) .then(resp => chai.expect(resp).to.equal('should have thrown')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); + chai.expect(err.status).to.equal(400); }); }); @@ -599,7 +566,7 @@ describe('Users API', () => { .request(onlineRequestOptions) .then(resp => chai.expect(resp).to.equal('should have thrown')) .catch(err => { - chai.expect(err.statusCode).to.equal(400); + chai.expect(err.status).to.equal(400); }); }); @@ -609,7 +576,6 @@ describe('Users API', () => { facility_id: 'IdonTExist' }; onlineRequestOptions.path += '?' + querystring.stringify(params); - onlineRequestOptions.headers = { 'Content-Type': 'application/json' }; return utils .request(onlineRequestOptions) .then(resp => { @@ -682,18 +648,17 @@ describe('Users API', () => { const opts = { uri: url, method: 'POST', - simple: false, resolveWithFullResponse: true, noAuth: true, - followRedirect: false, + redirect: 'manual', body: {}, headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts).then(response => { - chai.expect(response).to.include({ statusCode: 302, body: '/' }); - chai.expect(response.headers['set-cookie']).to.be.an('array'); - chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; - chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + chai.expect(response).to.include({ status: 302, body: '/' }); + chai.expect(response.headers.getSetCookie()).to.be.an('array'); + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('userCtx'))).to.be.ok; }); }; @@ -701,16 +666,15 @@ describe('Users API', () => { const opts = { uri: url, method: 'POST', - simple: false, noAuth: true, - followRedirect: false, + redirect: 'manual', resolveWithFullResponse: true, body: {}, headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts).then(response => { - chai.expect(response.headers['set-cookie']).to.be.undefined; - chai.expect(response).to.deep.include({ statusCode: 401, body: { error: expired ? 'expired': 'invalid' } }); + chai.expect(response.headers.getSetCookie()).to.deep.equal([]); + chai.expect(response).to.deep.include({ status: 401, body: { error: expired ? 'expired': 'invalid' } }); }); }; @@ -1253,8 +1217,8 @@ describe('Users API', () => { }) .then(() => chai.assert.fail('should have thrown')) .catch(err => { - chai.expect(err.response).to.shallowDeepEqual({ - statusCode: 400, + chai.expect(err).to.shallowDeepEqual({ + status: 400, body: { code: 400, error: { message: 'Missing required fields: phone' }} }); }); @@ -1273,8 +1237,8 @@ describe('Users API', () => { }) .then(() => chai.assert.fail('should have thrown')) .catch(err => { - chai.expect(err.response).to.shallowDeepEqual({ - statusCode: 400, + chai.expect(err).to.shallowDeepEqual({ + status: 400, body: { code: 400, error: { message: 'Missing required fields: phone' }} }); @@ -1610,7 +1574,7 @@ describe('Users API', () => { chai.expect(tokenLoginDoc.user).to.equal('org.couchdb.user:testuser'); const onlineRequestOpts = { - auth: { user: 'onlineuser', password }, + auth: { username: 'onlineuser', password }, method: 'PUT', path: `/${tokenLoginDoc._id}`, body: tokenLoginDoc, @@ -1618,8 +1582,8 @@ describe('Users API', () => { return utils.requestOnTestDb(onlineRequestOpts).catch(err => err); }) .then(err => { - chai.expect(err.response).to.deep.include({ - statusCode: 403, + chai.expect(err).to.deep.include({ + status: 403, body: { error: 'forbidden', reason: 'Insufficient privileges' @@ -1655,7 +1619,7 @@ describe('Users API', () => { await utils.saveDocs([ facility, person ]); await utils.createUsers([{ ...user, password }, { ...userProgramOfficer, password }]); - await utils.updatePermissions(['program_officer'], ['can_view_users']); + await utils.updatePermissions(['program_officer'], ['can_view_users'], [], { ignoreReload: true }); }); after(async () => { @@ -1694,9 +1658,9 @@ describe('Users API', () => { await utils.request({ path: `/api/v2/users/invalidUsername`, }); - } catch ({ error }) { - expect(error.code).to.equal(404); - expect(error.error).to + } catch ({ body }) { + expect(body.code).to.equal(404); + expect(body.error).to .match(/Failed to find user with name \[invalidUsername\] in the \[(users|medic)\] database./); return; } @@ -1710,9 +1674,9 @@ describe('Users API', () => { path: `/api/v2/users/${userProgramOfficer.username}`, auth: { username: user.username, password }, }); - } catch ({ error }) { - expect(error.code).to.equal(403); - expect(error.error).to.equal('Insufficient privileges'); + } catch ({ body }) { + expect(body.code).to.equal(403); + expect(body.error).to.equal('Insufficient privileges'); return; } @@ -1944,7 +1908,12 @@ describe('Users API', () => { }); it('should create users with multiple facilities', async () => { - await utils.updatePermissions(['national_admin', 'chw'], ['can_have_multiple_places'], [], true); + await utils.updatePermissions( + ['national_admin', 'chw'], + ['can_have_multiple_places'], + [], + { ignoreReload: true } + ); const onlineUserPayload = { username: uuid(), password: password, @@ -2007,13 +1976,13 @@ describe('Users API', () => { await utils.request({ path: '/api/v3/users', method: 'POST', body: offlineUserPayload }); expect.fail('Should have thrown'); } catch (error) { - expect(error.statusCode).to.equal(400); - expect(error.error.error.message).to.equal('This user cannot have multiple places'); + expect(error.status).to.equal(400); + expect(error.body.error.message).to.equal('This user cannot have multiple places'); } }); it('should edit users to add multiple facilities', async () => { - await utils.updatePermissions(['national_admin'], ['can_have_multiple_places']); + await utils.updatePermissions(['national_admin'], ['can_have_multiple_places'], [], { ignoreReload: true }); const onlineUserPayload = { username: uuid(), password: password, @@ -2049,7 +2018,12 @@ describe('Users API', () => { }); it('should fail when facilities are malformed', async () => { - await utils.updatePermissions(['national_admin', 'chw'], ['can_have_multiple_places'], [], true); + await utils.updatePermissions( + ['national_admin', 'chw'], + ['can_have_multiple_places'], + [], + { ignoreReload: true } + ); const onlineUserPayload = { username: uuid(), password: password, @@ -2062,8 +2036,8 @@ describe('Users API', () => { await utils.request({ path: '/api/v3/users', method: 'POST', body: onlineUserPayload }); expect.expect.fail('Should have thrown'); } catch (err) { - expect(err.responseBody.code).to.equal(400); - expect(err.responseBody.error.message).to.equal('Invalid facilities list'); + expect(err.body.code).to.equal(400); + expect(err.body.error.message).to.equal('Invalid facilities list'); } const offlineUserPayload = { @@ -2078,8 +2052,8 @@ describe('Users API', () => { await utils.request({ path: '/api/v3/users', method: 'POST', body: offlineUserPayload }); expect.expect.fail('Should have thrown'); } catch (err) { - expect(err.responseBody.code).to.equal(400); - expect(err.responseBody.error.message).to.equal('Missing required fields: place'); + expect(err.body.code).to.equal(400); + expect(err.body.error.message).to.equal('Missing required fields: place'); } }); }); diff --git a/tests/integration/api/controllers/well-known.spec.js b/tests/integration/api/controllers/well-known.spec.js index 6d77886ce78..a27d0bf94ea 100644 --- a/tests/integration/api/controllers/well-known.spec.js +++ b/tests/integration/api/controllers/well-known.spec.js @@ -17,7 +17,7 @@ describe('well-known', () => { }) .then(() => chai.expect.fail('should have thrown')) .catch(error => { - chai.expect(error.response.statusCode).to.equal(404); + chai.expect(error.status).to.equal(404); }); }); diff --git a/tests/integration/api/rate-limit.spec.js b/tests/integration/api/rate-limit.spec.js index 677ff00bf2e..f7bb6791404 100644 --- a/tests/integration/api/rate-limit.spec.js +++ b/tests/integration/api/rate-limit.spec.js @@ -18,7 +18,7 @@ describe('rate limit', () => { await requestThat401s(); expect.fail('should have been rate limited'); } catch (e) { - expect(e.statusCode).to.equal(429); + expect(e.status).to.equal(429); } await new Promise((resolve) => { @@ -30,7 +30,7 @@ describe('rate limit', () => { expect.fail('should have rejected due to no auth'); } catch (e) { // the rate limit period has passed, so we're back to 401s - expect(e.statusCode).to.equal(401); + expect(e.status).to.equal(401); } }); diff --git a/tests/integration/api/routing.spec.js b/tests/integration/api/routing.spec.js index dcbbad60fc4..affb0cae7cb 100644 --- a/tests/integration/api/routing.spec.js +++ b/tests/integration/api/routing.spec.js @@ -142,9 +142,9 @@ describe('routing', () => { .request(options) .catch(err => err) .then(result => { - expect(result.statusCode).to.equal(401); - expect(result.response.headers['logout-authorization']).to.equal('CHT-Core API'); - expect(result.responseBody.error).to.equal('unauthorized'); + expect(result.status).to.equal(401); + expect(result.headers.get('logout-authorization')).to.equal('CHT-Core API'); + expect(result.body.error).to.equal('unauthorized'); }); }); }); @@ -234,12 +234,12 @@ describe('routing', () => { results.forEach((result, idx) => { if (idx === 0) { // online user request - expect(result.statusCode).to.equal(404); - expect(result.responseBody.error).to.equal('not_found'); + expect(result.status).to.equal(404); + expect(result.body.error).to.equal('not_found'); } else { // offline user requests - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -275,12 +275,12 @@ describe('routing', () => { results.forEach((result, idx) => { if (idx === 0) { // online user request - expect(result.statusCode).to.equal(404); - expect(result.responseBody.error).to.equal('not_found'); + expect(result.status).to.equal(404); + expect(result.body.error).to.equal('not_found'); } else { // offline user requests - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -317,12 +317,12 @@ describe('routing', () => { results.forEach((result, idx) => { if (idx === 0) { // online user request - expect(result.statusCode).to.equal(404); - expect(result.responseBody.error).to.equal('not_found'); + expect(result.status).to.equal(404); + expect(result.body.error).to.equal('not_found'); } else { // offline user requests - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -363,8 +363,8 @@ describe('routing', () => { expect(result.docs.length).to.be.above(0); } else { // offline user request - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -403,11 +403,11 @@ describe('routing', () => { if (idx === 0) { // online user request expect(result.limit).to.equal(1); - expect(result.fields).to.equal('all_fields'); + expect(result.fields).to.deep.equal([]); } else { // offline user requests - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -444,8 +444,8 @@ describe('routing', () => { expect(result.indexes.length).to.equal(1); } else { // offline user request - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -488,8 +488,8 @@ describe('routing', () => { expect(result.ok).to.equal(true); } else { // offline user request - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -522,11 +522,11 @@ describe('routing', () => { results.forEach((result, idx) => { if (idx === 0) { // online user request - expect(result.statusCode).to.be.undefined; + expect(result.status).to.be.undefined; } else { // offline user requests - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); } }); }); @@ -559,8 +559,8 @@ describe('routing', () => { .catch(err => err) ]).then(results => { results.forEach(result => { - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); }); }); }); @@ -658,8 +658,8 @@ describe('routing', () => { ]) .then(results => { results.forEach(result => { - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); }); }); }); @@ -695,8 +695,8 @@ describe('routing', () => { .catch(err => err), ]).then(results => { results.forEach(result => { - expect(result.statusCode).to.equal(403); - expect(result.responseBody.error).to.equal('forbidden'); + expect(result.status).to.equal(403); + expect(result.body.error).to.equal('forbidden'); }); }); }); @@ -739,17 +739,17 @@ describe('routing', () => { return createSession() .then(res => { - expect(res.statusCode).to.equal(200); - expect(res.headers['set-cookie'].length).to.equal(1); - const sessionCookie = res.headers['set-cookie'][0].split(';')[0]; + expect(res.status).to.equal(200); + expect(res.headers.getSetCookie().length).to.equal(1); + const sessionCookie = res.headers.getSetCookie()[0].split(';')[0]; expect(sessionCookie.split('=')[0]).to.equal('AuthSession'); return sessionCookie; }) .then(sessionCookie => getSession(sessionCookie)) .then(res => { - expect(res.statusCode).to.equal(200); - expect(res.headers['set-cookie'].length).to.equal(1); - const [ content, age, path, expires, samesite ] = res.headers['set-cookie'][0].split('; '); + expect(res.status).to.equal(200); + expect(res.headers.getSetCookie().length).to.equal(1); + const [ content, age, path, expires, samesite ] = res.headers.getSetCookie()[0].split('; '); // check the cookie content is unchanged const [ contentKey, contentValue ] = content.split('='); @@ -827,7 +827,7 @@ describe('routing', () => { return utils.requestOnTestDb(_.defaults(params, offlineRequestOptions)).catch(err => err); }) .then(response => { - expect(response.statusCode).to.equal(403); + expect(response.status).to.equal(403); }) .then(() => { const params = { @@ -838,7 +838,7 @@ describe('routing', () => { return utils.requestOnMedicDb(_.defaults(params, offlineRequestOptions)).catch(err => err); }) .then(response => { - expect(response.statusCode).to.equal(403); + expect(response.status).to.equal(403); }) .then(() => utils.getDoc('settings')) .then(settings => { diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 16c529221c6..bc6d056347c 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -1,5 +1,4 @@ const utils = require('@utils'); -const request = require('request'); const constants = require('@constants'); const _ = require('lodash'); const placeFactory = require('@factories/cht/contacts/place'); @@ -15,80 +14,61 @@ describe('server', () => { json: false }; - return utils.requestOnTestDb(opts, true) + return utils.requestOnTestDb(opts) .then(() => expect.fail('should have thrown')) .catch(e => { - expect(e.responseBody).to.equal('Content-Type must be application/json'); + expect(e.body).to.equal('Content-Type must be application/json'); }); }); }); describe('response compression', () => { - const requestWrapper = (options) => { - _.defaults(options, { - auth: { - sendImmediately: true, - username: constants.USERNAME, - password: constants.PASSWORD - }, - method: 'GET', - baseUrl: constants.BASE_URL + '/' + constants.DB_NAME, - uri: '/', - gzip: true - }); + const requestWrapper = async (options) => { + const opts = { path: '/', gzip: true, resolveWithFullResponse: true, ...options }; - return new Promise((resolve, reject) => { - request(options, (err, res, body) => { - if (err) { - return reject(err); - } - - if (res.headers['content-type'] === 'application/json' && typeof body === 'string') { - try { - body = JSON.parse(body); - } catch (err) { - // an error occured when trying parse 'body' to Object - } - } - - resolve({ res, body }); - }); - }); + const res = await utils.request(opts); + return { res, body: res.body }; }; it('compresses proxied CouchDB application/json requests which send accept-encoding gzip headers', () => { - const options = { uri: '/_all_docs' }; + const options = { path: '/medic/_all_docs' }; return requestWrapper(options).then(({res}) => { - expect(res.headers['content-encoding']).to.equal('gzip'); - expect(res.headers['content-type']).to.equal('application/json'); + expect(res.headers.get('content-encoding')).to.equal('gzip'); + expect(res.headers.get('content-type')).to.equal('application/json'); }); }); it('compresses proxied CouchDB application/json requests which send accept-encoding deflate headers', () => { - const options = { uri: '/_all_docs', gzip: false, headers: { 'Accept-Encoding': 'deflate' } }; + const options = { + path: '/medic/_all_docs', + gzip: false, + headers: { 'Accept-Encoding': 'deflate' } + }; return requestWrapper(options).then(({res}) => { - expect(res.headers['content-encoding']).to.equal('deflate'); - expect(res.headers['content-type']).to.equal('application/json'); + expect(res.headers.get('content-encoding')).to.equal('deflate'); + expect(res.headers.get('content-type')).to.equal('application/json'); }); }); it('does not compress when no accept-encoding headers are sent', () => { - const options = { uri: '/_all_docs', gzip: false }; + const options = { + path: '/medic/_all_docs', + gzip: false + }; return requestWrapper(options).then(({res}) => { - expect(res.headers['content-type']).to.equal('application/json'); - expect(res.headers['content-encoding']).to.be.undefined; + expect(res.headers.get('content-type')).to.equal('application/json'); + expect(res.headers.get('content-encoding')).to.be.null; }); }); it('compresses audited endpoints responses', () => { // compression threshold is 1024B const options = { - uri: '/_bulk_docs', + path: '/medic/_bulk_docs', method: 'POST', - json: true, body: { docs: [ { _id: 'sample_doc' }, { _id: 'sample_doc2' }, { _id: 'sample_doc3' }, @@ -102,9 +82,8 @@ describe('server', () => { }; return requestWrapper(options).then(({res, body}) => { - expect(res.headers['content-type']).to.equal('application/json'); - expect(res.headers['content-encoding']).to.equal('gzip'); - expect(body.length).to.equal(18); + expect(res.headers.get('content-type')).to.equal('application/json'); + expect(res.headers.get('content-encoding')).to.equal('gzip'); expect(_.omit(body[0], 'rev')).to.eql({ id: 'sample_doc', ok: true }); expect(_.omit(body[1], 'rev')).to.eql({ id: 'sample_doc2', ok: true }); expect(_.omit(body[2], 'rev')).to.eql({ id: 'sample_doc3', ok: true }); @@ -116,22 +95,28 @@ describe('server', () => { .getDoc('sample_doc') .then(doc => { const options = { - uri: '/sample_doc/attach?rev=' + doc._rev, + path: '/medic/sample_doc/attach', body: 'my-attachment-content', headers: { 'Content-Type': 'text/plain' }, - method: 'PUT' + method: 'PUT', + json: false, + qs: { rev: doc._rev } }; return requestWrapper(options); }) .then(({body}) => { - const options = { uri: '/sample_doc/attach?rev=' + body.rev}; + const options = { + path: '/medic//sample_doc/attach', + json: false, + qs: { rev: body.rev } + }; return requestWrapper(options); }) .then(({res, body}) => { - expect(res.headers['content-type']).to.equal('text/plain'); - expect(res.headers['content-encoding']).to.equal('gzip'); + expect(res.headers.get('content-type')).to.equal('text/plain'); + expect(res.headers.get('content-encoding')).to.equal('gzip'); expect(body).to.equal('my-attachment-content'); }); }); @@ -144,22 +129,27 @@ describe('server', () => { .getDoc('sample_doc2') .then(doc => { const options = { - uri: '/sample_doc2/attach?rev=' + doc._rev, + path: '/medic/sample_doc2/attach', body: xml, + json: false, headers: { 'Content-Type': 'application/xml' }, - method: 'PUT' + method: 'PUT', + qs: { rev: doc._rev } }; return requestWrapper(options); }) .then(({body}) => { - const options = { uri: '/sample_doc2/attach?rev=' + body.rev}; + const options = { + path: '/medic/sample_doc2/attach', + qs: { rev: body.rev } + }; return requestWrapper(options); }) .then(({res, body}) => { - expect(res.headers['content-type']).to.equal('application/xml'); - expect(res.headers['content-encoding']).to.equal('gzip'); + expect(res.headers.get('content-type')).to.equal('application/xml'); + expect(res.headers.get('content-encoding')).to.equal('gzip'); expect(body).to.equal(xml); }); }); @@ -170,16 +160,21 @@ describe('server', () => { 'Person 1.1.2.1'; const doc = await utils.getDoc('sample_doc2'); const options = { - uri: '/sample_doc2/attach?rev='+doc._rev, + path: '/medic/sample_doc2/attach', body: png, headers: { 'Content-Type': 'image/png' }, - method: 'PUT' + method: 'PUT', + qs: { rev: doc._rev }, }; const { body } = await requestWrapper(options); - const getAttachmentOptions = { uri: '/sample_doc2/attach?rev=' + body.rev }; + const getAttachmentOptions = { + path: '/medic/sample_doc2/attach', + qs: { rev: body.rev }, + json: false, + }; const { res, body: attachmentBody } = await requestWrapper(getAttachmentOptions); - expect(res.headers[ 'content-type' ]).to.equal('image/png'); - expect(res.headers[ 'content-encoding' ]).to.be.undefined; + expect(res.headers.get('content-type')).to.equal('image/png'); + expect(res.headers.get('content-encoding')).to.be.null; expect(attachmentBody).to.equal(png); }); }); @@ -430,4 +425,28 @@ describe('server', () => { }); }); + + describe('api startup', () => { + it('should start up with broken forms', async () => { + const waitForLogs = await utils.waitForApiLogs(/Failed to update xform/); + + const formName = 'broken'; + const formDoc = { + _id: `form:${formName}`, + title: formName, + type: 'form', + _attachments: { + xml: { + content_type: 'application/octet-stream', + data: btoa('this is totally not an xml'), + }, + }, + }; + await utils.db.put(formDoc); // don't use utils.saveDoc because that actually waits for good forms + await waitForLogs.promise; + + await utils.stopApi(); + await utils.startApi(); + }); + }); }); diff --git a/tests/integration/cht-form/default/person-edit.wdio-spec.js b/tests/integration/cht-form/default/person-edit.wdio-spec.js index 9220a09228b..f40bf01dfcd 100644 --- a/tests/integration/cht-form/default/person-edit.wdio-spec.js +++ b/tests/integration/cht-form/default/person-edit.wdio-spec.js @@ -40,9 +40,6 @@ describe('cht-form web component - Edit Person Form', () => { created_by_place_uuid: '' } }, - meta: { - instanceID: 'uuid:c558e232-0951-4ed8-8392-5ed8ac3c81b3' - } }; }); diff --git a/tests/integration/cht-form/wdio.conf.js b/tests/integration/cht-form/wdio.conf.js index 2d89f9daa43..29799c128d6 100644 --- a/tests/integration/cht-form/wdio.conf.js +++ b/tests/integration/cht-form/wdio.conf.js @@ -11,6 +11,17 @@ const logPath = path.join('tests', 'logs'); const browserLogPath = path.join(logPath, 'browser.console.log'); const mockConfig = require('./mock-config'); +const ALLOWED_LOG_MSGS = [ + 'favicon.ico - Failed to load resource: the server responded with a status of 404 (Not Found)', + 'tag was parsed inside of a