evaldoc 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- package/tests/unit/validate.test.js +408 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the doctor feedback module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
6
|
+
|
|
7
|
+
const admin = require('firebase-admin');
|
|
8
|
+
const { captureDoctorExample, retrieveRelevantExamples, getFeedbackStats } = require('../../functions/src/feedback');
|
|
9
|
+
|
|
10
|
+
describe('Feedback Module', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
admin.__reset();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('captureDoctorExample', () => {
|
|
16
|
+
it('should store a doctor example in Firestore', async () => {
|
|
17
|
+
// Seed a thread so the update doesn't fail
|
|
18
|
+
admin.__seed('threads', 'thread-1', { doctorId: 'doc-1' });
|
|
19
|
+
|
|
20
|
+
const result = await captureDoctorExample({
|
|
21
|
+
doctorId: 'doc-1',
|
|
22
|
+
threadId: 'thread-1',
|
|
23
|
+
topic: 'general_health_info',
|
|
24
|
+
patientMessage: 'I have a headache',
|
|
25
|
+
doctorResponse: 'Try resting and staying hydrated.',
|
|
26
|
+
wasAiDraft: false,
|
|
27
|
+
aiDraftOriginal: null
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result.id).toBeDefined();
|
|
31
|
+
expect(result.patientMessage).toBe('I have a headache');
|
|
32
|
+
expect(result.doctorResponse).toBe('Try resting and staying hydrated.');
|
|
33
|
+
expect(result.wasAiDraft).toBe(false);
|
|
34
|
+
expect(result.editDistance).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should calculate edit distance when AI draft was edited', async () => {
|
|
38
|
+
admin.__seed('threads', 'thread-2', { doctorId: 'doc-1' });
|
|
39
|
+
|
|
40
|
+
const result = await captureDoctorExample({
|
|
41
|
+
doctorId: 'doc-1',
|
|
42
|
+
threadId: 'thread-2',
|
|
43
|
+
topic: 'general_health_info',
|
|
44
|
+
patientMessage: 'I have a headache',
|
|
45
|
+
doctorResponse: 'I recommend rest, hydration, and ibuprofen.',
|
|
46
|
+
wasAiDraft: true,
|
|
47
|
+
aiDraftOriginal: 'Based on your symptoms, rest and hydration are recommended.'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.wasAiDraft).toBe(true);
|
|
51
|
+
expect(result.editDistance).toBeDefined();
|
|
52
|
+
expect(typeof result.editDistance).toBe('number');
|
|
53
|
+
expect(result.editDistance).toBeGreaterThan(0);
|
|
54
|
+
expect(result.editDistance).toBeLessThanOrEqual(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should truncate long messages', async () => {
|
|
58
|
+
admin.__seed('threads', 'thread-3', { doctorId: 'doc-1' });
|
|
59
|
+
|
|
60
|
+
const longMessage = 'x'.repeat(2000);
|
|
61
|
+
const result = await captureDoctorExample({
|
|
62
|
+
doctorId: 'doc-1',
|
|
63
|
+
threadId: 'thread-3',
|
|
64
|
+
topic: 'general_health_info',
|
|
65
|
+
patientMessage: longMessage,
|
|
66
|
+
doctorResponse: 'Short response',
|
|
67
|
+
wasAiDraft: false,
|
|
68
|
+
aiDraftOriginal: null
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.patientMessage.length).toBeLessThanOrEqual(500);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('retrieveRelevantExamples', () => {
|
|
76
|
+
it('should return empty array when no examples exist', async () => {
|
|
77
|
+
const result = await retrieveRelevantExamples({
|
|
78
|
+
doctorId: 'doc-1',
|
|
79
|
+
topic: 'general_health_info',
|
|
80
|
+
limit: 3
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return examples from Firestore', async () => {
|
|
87
|
+
// Seed some examples
|
|
88
|
+
const col = 'providers/doc-1/doctorExamples';
|
|
89
|
+
admin.__seed(col, 'ex-1', {
|
|
90
|
+
topic: 'general_health_info',
|
|
91
|
+
patientMessage: 'headache',
|
|
92
|
+
doctorResponse: 'rest',
|
|
93
|
+
createdAt: { _seconds: 1000 }
|
|
94
|
+
});
|
|
95
|
+
admin.__seed(col, 'ex-2', {
|
|
96
|
+
topic: 'general_health_info',
|
|
97
|
+
patientMessage: 'cough',
|
|
98
|
+
doctorResponse: 'hydrate',
|
|
99
|
+
createdAt: { _seconds: 2000 }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = await retrieveRelevantExamples({
|
|
103
|
+
doctorId: 'doc-1',
|
|
104
|
+
topic: 'general_health_info',
|
|
105
|
+
limit: 3
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.length).toBe(2);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('getFeedbackStats', () => {
|
|
113
|
+
it('should return zero stats when no examples exist', async () => {
|
|
114
|
+
const stats = await getFeedbackStats({ doctorId: 'doc-1' });
|
|
115
|
+
|
|
116
|
+
expect(stats.totalExamples).toBe(0);
|
|
117
|
+
expect(stats.aiDraftBased).toBe(0);
|
|
118
|
+
expect(stats.manualResponses).toBe(0);
|
|
119
|
+
expect(stats.avgEditDistance).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should compute correct stats from examples', async () => {
|
|
123
|
+
const col = 'providers/doc-1/doctorExamples';
|
|
124
|
+
admin.__seed(col, 'ex-1', {
|
|
125
|
+
wasAiDraft: true,
|
|
126
|
+
editDistance: 0.3,
|
|
127
|
+
createdAt: { _seconds: 1000 }
|
|
128
|
+
});
|
|
129
|
+
admin.__seed(col, 'ex-2', {
|
|
130
|
+
wasAiDraft: true,
|
|
131
|
+
editDistance: 0.5,
|
|
132
|
+
createdAt: { _seconds: 2000 }
|
|
133
|
+
});
|
|
134
|
+
admin.__seed(col, 'ex-3', {
|
|
135
|
+
wasAiDraft: false,
|
|
136
|
+
editDistance: null,
|
|
137
|
+
createdAt: { _seconds: 3000 }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const stats = await getFeedbackStats({ doctorId: 'doc-1' });
|
|
141
|
+
|
|
142
|
+
expect(stats.totalExamples).toBe(3);
|
|
143
|
+
expect(stats.aiDraftBased).toBe(2);
|
|
144
|
+
expect(stats.manualResponses).toBe(1);
|
|
145
|
+
expect(stats.avgEditDistance).toBe(0.4);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the i18n localization module.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - t() returns correct translation per supported locale
|
|
6
|
+
* - t() falls back to English for unknown locale / unknown key
|
|
7
|
+
* - normalizeLocale handles region tags, casing, and unsupported input
|
|
8
|
+
* - SUPPORTED_LOCALES is exactly the 6 expected locales
|
|
9
|
+
* - Every MESSAGES key exists in every locale (no missing translations)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
SUPPORTED_LOCALES,
|
|
14
|
+
MESSAGES,
|
|
15
|
+
normalizeLocale,
|
|
16
|
+
t,
|
|
17
|
+
DEFAULT_LOCALE
|
|
18
|
+
} = require('../../functions/src/i18n');
|
|
19
|
+
|
|
20
|
+
const EXPECTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ar'];
|
|
21
|
+
const KEYS = ['disclaimer_footer', 'refusal', 'escalation', 'emergency_banner'];
|
|
22
|
+
|
|
23
|
+
describe('SUPPORTED_LOCALES', () => {
|
|
24
|
+
test('contains exactly the 6 expected locales', () => {
|
|
25
|
+
expect(SUPPORTED_LOCALES).toEqual(EXPECTED_LOCALES);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('has length 6', () => {
|
|
29
|
+
expect(SUPPORTED_LOCALES).toHaveLength(6);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('includes Arabic', () => {
|
|
33
|
+
expect(SUPPORTED_LOCALES).toContain('ar');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('default locale is a supported locale', () => {
|
|
37
|
+
expect(SUPPORTED_LOCALES).toContain(DEFAULT_LOCALE);
|
|
38
|
+
expect(DEFAULT_LOCALE).toBe('en');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('t() returns correct translation per locale', () => {
|
|
43
|
+
test('English disclaimer_footer', () => {
|
|
44
|
+
expect(t('disclaimer_footer', 'en')).toBe(
|
|
45
|
+
'This is general information, not medical advice. Consult a licensed clinician.'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('English refusal', () => {
|
|
50
|
+
expect(t('refusal', 'en')).toBe(
|
|
51
|
+
"I can't help with that. Please consult a licensed clinician for personalized medical advice."
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('English escalation', () => {
|
|
56
|
+
expect(t('escalation', 'en')).toBe(
|
|
57
|
+
'This may be urgent. Please seek emergency care or contact your local emergency number immediately.'
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('English emergency_banner', () => {
|
|
62
|
+
expect(t('emergency_banner', 'en')).toBe(
|
|
63
|
+
'If this is an emergency, call your local emergency number now.'
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('Spanish refusal matches table', () => {
|
|
68
|
+
expect(t('refusal', 'es')).toBe(MESSAGES.es.refusal);
|
|
69
|
+
expect(t('refusal', 'es')).not.toBe(MESSAGES.en.refusal);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('French escalation matches table', () => {
|
|
73
|
+
expect(t('escalation', 'fr')).toBe(MESSAGES.fr.escalation);
|
|
74
|
+
expect(t('escalation', 'fr')).not.toBe(MESSAGES.en.escalation);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('German disclaimer_footer matches table', () => {
|
|
78
|
+
expect(t('disclaimer_footer', 'de')).toBe(MESSAGES.de.disclaimer_footer);
|
|
79
|
+
expect(t('disclaimer_footer', 'de')).not.toBe(MESSAGES.en.disclaimer_footer);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('Portuguese emergency_banner matches table', () => {
|
|
83
|
+
expect(t('emergency_banner', 'pt')).toBe(MESSAGES.pt.emergency_banner);
|
|
84
|
+
expect(t('emergency_banner', 'pt')).not.toBe(MESSAGES.en.emergency_banner);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('Arabic refusal matches table and is non-empty', () => {
|
|
88
|
+
expect(t('refusal', 'ar')).toBe(MESSAGES.ar.refusal);
|
|
89
|
+
expect(t('refusal', 'ar').length).toBeGreaterThan(0);
|
|
90
|
+
expect(t('refusal', 'ar')).not.toBe(MESSAGES.en.refusal);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('returns a translation for every key in every supported locale', () => {
|
|
94
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
95
|
+
for (const key of KEYS) {
|
|
96
|
+
expect(t(key, locale)).toBe(MESSAGES[locale][key]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('t() applies normalization to the locale argument', () => {
|
|
102
|
+
expect(t('refusal', 'es-ES')).toBe(MESSAGES.es.refusal);
|
|
103
|
+
expect(t('refusal', 'FR')).toBe(MESSAGES.fr.refusal);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('t() fallback behavior', () => {
|
|
108
|
+
test('falls back to English for an unknown locale', () => {
|
|
109
|
+
expect(t('refusal', 'zz')).toBe(MESSAGES.en.refusal);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('falls back to English for null locale', () => {
|
|
113
|
+
expect(t('escalation', null)).toBe(MESSAGES.en.escalation);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('falls back to English for undefined locale', () => {
|
|
117
|
+
expect(t('escalation', undefined)).toBe(MESSAGES.en.escalation);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('falls back to English for an unknown key in a known locale', () => {
|
|
121
|
+
// Unknown key does not exist anywhere -> empty string.
|
|
122
|
+
expect(t('does_not_exist', 'es')).toBe('');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('returns empty string for an unknown key with English locale', () => {
|
|
126
|
+
expect(t('totally_missing', 'en')).toBe('');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('uses English when a key is missing only in a non-English locale', () => {
|
|
130
|
+
// Simulate a partially-translated locale by temporarily deleting a key.
|
|
131
|
+
const saved = MESSAGES.es.refusal;
|
|
132
|
+
delete MESSAGES.es.refusal;
|
|
133
|
+
try {
|
|
134
|
+
expect(t('refusal', 'es')).toBe(MESSAGES.en.refusal);
|
|
135
|
+
} finally {
|
|
136
|
+
MESSAGES.es.refusal = saved;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('normalizeLocale()', () => {
|
|
142
|
+
test("'en-US' -> 'en'", () => {
|
|
143
|
+
expect(normalizeLocale('en-US')).toBe('en');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("'ES' -> 'es'", () => {
|
|
147
|
+
expect(normalizeLocale('ES')).toBe('es');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("'pt_BR' -> 'pt' (underscore separator)", () => {
|
|
151
|
+
expect(normalizeLocale('pt_BR')).toBe('pt');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("'fr-CA' -> 'fr'", () => {
|
|
155
|
+
expect(normalizeLocale('fr-CA')).toBe('fr');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("'AR' -> 'ar'", () => {
|
|
159
|
+
expect(normalizeLocale('AR')).toBe('ar');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("unsupported 'xx' -> 'en'", () => {
|
|
163
|
+
expect(normalizeLocale('xx')).toBe('en');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('null -> en', () => {
|
|
167
|
+
expect(normalizeLocale(null)).toBe('en');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('undefined -> en', () => {
|
|
171
|
+
expect(normalizeLocale(undefined)).toBe('en');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('empty string -> en', () => {
|
|
175
|
+
expect(normalizeLocale('')).toBe('en');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('whitespace-only string -> en', () => {
|
|
179
|
+
expect(normalizeLocale(' ')).toBe('en');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('non-string input -> en', () => {
|
|
183
|
+
expect(normalizeLocale(42)).toBe('en');
|
|
184
|
+
expect(normalizeLocale({})).toBe('en');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('already-normalized locales are returned unchanged', () => {
|
|
188
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
189
|
+
expect(normalizeLocale(locale)).toBe(locale);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('mixed-case with region -> base locale', () => {
|
|
194
|
+
expect(normalizeLocale('De-de')).toBe('de');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('MESSAGES integrity', () => {
|
|
199
|
+
test('has an entry for every supported locale', () => {
|
|
200
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
201
|
+
expect(MESSAGES).toHaveProperty(locale);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('every key exists in every locale (no missing translations)', () => {
|
|
206
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
207
|
+
for (const key of KEYS) {
|
|
208
|
+
expect(MESSAGES[locale]).toHaveProperty(key);
|
|
209
|
+
expect(typeof MESSAGES[locale][key]).toBe('string');
|
|
210
|
+
expect(MESSAGES[locale][key].trim().length).toBeGreaterThan(0);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('every locale has the same set of keys as English', () => {
|
|
216
|
+
const enKeys = Object.keys(MESSAGES.en).sort();
|
|
217
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
218
|
+
expect(Object.keys(MESSAGES[locale]).sort()).toEqual(enKeys);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('non-English translations differ from English', () => {
|
|
223
|
+
for (const locale of EXPECTED_LOCALES.filter((l) => l !== 'en')) {
|
|
224
|
+
for (const key of KEYS) {
|
|
225
|
+
expect(MESSAGES[locale][key]).not.toBe(MESSAGES.en[key]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const { withRetry } = require('../../functions/src/llm');
|
|
2
|
+
|
|
3
|
+
describe('withRetry', () => {
|
|
4
|
+
it('should succeed on first attempt', async () => {
|
|
5
|
+
const fn = jest.fn().mockResolvedValue('ok');
|
|
6
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
7
|
+
expect(result).toBe('ok');
|
|
8
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should retry on 429 rate limit', async () => {
|
|
12
|
+
const fn = jest.fn()
|
|
13
|
+
.mockRejectedValueOnce({ status: 429, message: 'rate limited' })
|
|
14
|
+
.mockResolvedValue('ok');
|
|
15
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
16
|
+
expect(result).toBe('ok');
|
|
17
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should retry on 500 server error', async () => {
|
|
21
|
+
const fn = jest.fn()
|
|
22
|
+
.mockRejectedValueOnce({ status: 500, message: 'server error' })
|
|
23
|
+
.mockResolvedValue('ok');
|
|
24
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
25
|
+
expect(result).toBe('ok');
|
|
26
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should retry on 502 gateway error', async () => {
|
|
30
|
+
const fn = jest.fn()
|
|
31
|
+
.mockRejectedValueOnce({ status: 502, message: 'bad gateway' })
|
|
32
|
+
.mockResolvedValue('ok');
|
|
33
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
34
|
+
expect(result).toBe('ok');
|
|
35
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should retry on network errors', async () => {
|
|
39
|
+
const fn = jest.fn()
|
|
40
|
+
.mockRejectedValueOnce({ code: 'ECONNRESET', message: 'connection reset' })
|
|
41
|
+
.mockResolvedValue('ok');
|
|
42
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
43
|
+
expect(result).toBe('ok');
|
|
44
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should retry on ETIMEDOUT', async () => {
|
|
48
|
+
const fn = jest.fn()
|
|
49
|
+
.mockRejectedValueOnce({ code: 'ETIMEDOUT', message: 'timeout' })
|
|
50
|
+
.mockResolvedValue('ok');
|
|
51
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
52
|
+
expect(result).toBe('ok');
|
|
53
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should NOT retry on 400 bad request', async () => {
|
|
57
|
+
const fn = jest.fn()
|
|
58
|
+
.mockRejectedValue({ status: 400, message: 'bad request' });
|
|
59
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 }))
|
|
60
|
+
.rejects.toMatchObject({ status: 400 });
|
|
61
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should NOT retry on 401 unauthorized', async () => {
|
|
65
|
+
const fn = jest.fn()
|
|
66
|
+
.mockRejectedValue({ status: 401, message: 'unauthorized' });
|
|
67
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 }))
|
|
68
|
+
.rejects.toMatchObject({ status: 401 });
|
|
69
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should NOT retry on 403 forbidden', async () => {
|
|
73
|
+
const fn = jest.fn()
|
|
74
|
+
.mockRejectedValue({ status: 403, message: 'forbidden' });
|
|
75
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 }))
|
|
76
|
+
.rejects.toMatchObject({ status: 403 });
|
|
77
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should exhaust retries and throw on persistent transient error', async () => {
|
|
81
|
+
const fn = jest.fn()
|
|
82
|
+
.mockRejectedValue({ status: 503, message: 'service unavailable' });
|
|
83
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 }))
|
|
84
|
+
.rejects.toMatchObject({ status: 503 });
|
|
85
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should succeed on last retry attempt', async () => {
|
|
89
|
+
const fn = jest.fn()
|
|
90
|
+
.mockRejectedValueOnce({ status: 500, message: 'fail 1' })
|
|
91
|
+
.mockRejectedValueOnce({ status: 502, message: 'fail 2' })
|
|
92
|
+
.mockResolvedValue('finally');
|
|
93
|
+
const result = await withRetry(fn, { maxRetries: 2, baseDelayMs: 1 });
|
|
94
|
+
expect(result).toBe('finally');
|
|
95
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should use maxRetries=0 for no retries', async () => {
|
|
99
|
+
const fn = jest.fn()
|
|
100
|
+
.mockRejectedValue({ status: 500, message: 'fail' });
|
|
101
|
+
await expect(withRetry(fn, { maxRetries: 0, baseDelayMs: 1 }))
|
|
102
|
+
.rejects.toMatchObject({ status: 500 });
|
|
103
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should handle errors without status code as non-transient', async () => {
|
|
107
|
+
const fn = jest.fn()
|
|
108
|
+
.mockRejectedValue(new Error('unknown error'));
|
|
109
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 }))
|
|
110
|
+
.rejects.toThrow('unknown error');
|
|
111
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should handle ECONNREFUSED as transient', async () => {
|
|
115
|
+
const fn = jest.fn()
|
|
116
|
+
.mockRejectedValueOnce({ code: 'ECONNREFUSED', message: 'refused' })
|
|
117
|
+
.mockResolvedValue('connected');
|
|
118
|
+
const result = await withRetry(fn, { maxRetries: 1, baseDelayMs: 1 });
|
|
119
|
+
expect(result).toBe('connected');
|
|
120
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the notification system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
6
|
+
|
|
7
|
+
const admin = require('firebase-admin');
|
|
8
|
+
const {
|
|
9
|
+
NOTIFICATION_TYPES,
|
|
10
|
+
createNotification,
|
|
11
|
+
notifyEscalation,
|
|
12
|
+
notifyDraftReady,
|
|
13
|
+
getNotifications,
|
|
14
|
+
getUnreadCount,
|
|
15
|
+
markNotificationRead,
|
|
16
|
+
markAllNotificationsRead
|
|
17
|
+
} = require('../../functions/src/notifications');
|
|
18
|
+
|
|
19
|
+
describe('Notifications Module', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
admin.__reset();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('NOTIFICATION_TYPES', () => {
|
|
25
|
+
it('should have all expected types', () => {
|
|
26
|
+
expect(NOTIFICATION_TYPES.ESCALATION).toBe('escalation');
|
|
27
|
+
expect(NOTIFICATION_TYPES.UNREAD).toBe('unread_messages');
|
|
28
|
+
expect(NOTIFICATION_TYPES.DAILY_DIGEST).toBe('daily_digest');
|
|
29
|
+
expect(NOTIFICATION_TYPES.NEW_THREAD).toBe('new_thread');
|
|
30
|
+
expect(NOTIFICATION_TYPES.DRAFT_READY).toBe('draft_ready');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('createNotification', () => {
|
|
35
|
+
it('should create a notification in Firestore', async () => {
|
|
36
|
+
const result = await createNotification({
|
|
37
|
+
doctorId: 'doc-1',
|
|
38
|
+
type: 'escalation',
|
|
39
|
+
title: 'Urgent',
|
|
40
|
+
body: 'Patient needs help',
|
|
41
|
+
threadId: 'thread-1'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.id).toBeDefined();
|
|
45
|
+
expect(result.doctorId).toBe('doc-1');
|
|
46
|
+
expect(result.type).toBe('escalation');
|
|
47
|
+
expect(result.read).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('notifyEscalation', () => {
|
|
52
|
+
it('should create an escalation notification', async () => {
|
|
53
|
+
// Seed thread and provider for the update calls
|
|
54
|
+
admin.__seed('threads', 'thread-1', { doctorId: 'doc-1' });
|
|
55
|
+
admin.__seed('providers', 'doc-1', { email: 'doc@test.com' });
|
|
56
|
+
|
|
57
|
+
await notifyEscalation({
|
|
58
|
+
doctorId: 'doc-1',
|
|
59
|
+
threadId: 'thread-1',
|
|
60
|
+
patientName: 'Jane',
|
|
61
|
+
reason: 'Chest pain reported'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const notifications = admin.__getAll('notifications');
|
|
65
|
+
const notifIds = Object.keys(notifications);
|
|
66
|
+
expect(notifIds.length).toBe(1);
|
|
67
|
+
|
|
68
|
+
const notif = notifications[notifIds[0]];
|
|
69
|
+
expect(notif.type).toBe('escalation');
|
|
70
|
+
expect(notif.title).toMatch(/Urgent/i);
|
|
71
|
+
expect(notif.body).toContain('Jane');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('notifyDraftReady', () => {
|
|
76
|
+
it('should create a draft-ready notification', async () => {
|
|
77
|
+
await notifyDraftReady({
|
|
78
|
+
doctorId: 'doc-1',
|
|
79
|
+
threadId: 'thread-1',
|
|
80
|
+
patientName: 'Bob'
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const notifications = admin.__getAll('notifications');
|
|
84
|
+
const notif = Object.values(notifications)[0];
|
|
85
|
+
expect(notif.type).toBe('draft_ready');
|
|
86
|
+
expect(notif.body).toContain('Bob');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('getNotifications', () => {
|
|
91
|
+
it('should return notifications sorted by creation time', async () => {
|
|
92
|
+
admin.__seed('notifications', 'n1', {
|
|
93
|
+
doctorId: 'doc-1', type: 'escalation', read: false, createdAt: { _seconds: 1000 }
|
|
94
|
+
});
|
|
95
|
+
admin.__seed('notifications', 'n2', {
|
|
96
|
+
doctorId: 'doc-1', type: 'draft_ready', read: false, createdAt: { _seconds: 2000 }
|
|
97
|
+
});
|
|
98
|
+
admin.__seed('notifications', 'n3', {
|
|
99
|
+
doctorId: 'doc-2', type: 'escalation', read: false, createdAt: { _seconds: 3000 }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = await getNotifications({ doctorId: 'doc-1', limit: 10 });
|
|
103
|
+
expect(result.length).toBe(2);
|
|
104
|
+
// Should be sorted desc by createdAt
|
|
105
|
+
expect(result[0].id).toBe('n2');
|
|
106
|
+
expect(result[1].id).toBe('n1');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('getUnreadCount', () => {
|
|
111
|
+
it('should count only unread notifications for the doctor', async () => {
|
|
112
|
+
admin.__seed('notifications', 'n1', { doctorId: 'doc-1', read: false });
|
|
113
|
+
admin.__seed('notifications', 'n2', { doctorId: 'doc-1', read: true });
|
|
114
|
+
admin.__seed('notifications', 'n3', { doctorId: 'doc-1', read: false });
|
|
115
|
+
admin.__seed('notifications', 'n4', { doctorId: 'doc-2', read: false });
|
|
116
|
+
|
|
117
|
+
const count = await getUnreadCount({ doctorId: 'doc-1' });
|
|
118
|
+
expect(count).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('markNotificationRead', () => {
|
|
123
|
+
it('should mark a notification as read', async () => {
|
|
124
|
+
admin.__seed('notifications', 'n1', { doctorId: 'doc-1', read: false });
|
|
125
|
+
|
|
126
|
+
const result = await markNotificationRead({ notificationId: 'n1', doctorId: 'doc-1' });
|
|
127
|
+
expect(result).toBe(true);
|
|
128
|
+
|
|
129
|
+
const updated = admin.__getAll('notifications')['n1'];
|
|
130
|
+
expect(updated.read).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should reject marking another doctors notification', async () => {
|
|
134
|
+
admin.__seed('notifications', 'n1', { doctorId: 'doc-2', read: false });
|
|
135
|
+
|
|
136
|
+
const result = await markNotificationRead({ notificationId: 'n1', doctorId: 'doc-1' });
|
|
137
|
+
expect(result).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('markAllNotificationsRead', () => {
|
|
142
|
+
it('should mark all unread notifications as read', async () => {
|
|
143
|
+
admin.__seed('notifications', 'n1', { doctorId: 'doc-1', read: false });
|
|
144
|
+
admin.__seed('notifications', 'n2', { doctorId: 'doc-1', read: false });
|
|
145
|
+
admin.__seed('notifications', 'n3', { doctorId: 'doc-1', read: true });
|
|
146
|
+
|
|
147
|
+
const count = await markAllNotificationsRead({ doctorId: 'doc-1' });
|
|
148
|
+
expect(count).toBe(2);
|
|
149
|
+
|
|
150
|
+
const all = admin.__getAll('notifications');
|
|
151
|
+
expect(all['n1'].read).toBe(true);
|
|
152
|
+
expect(all['n2'].read).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|