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.
Files changed (139) hide show
  1. package/.env.staging.example +19 -0
  2. package/.firebaserc +6 -0
  3. package/.github/workflows/auto-merge-to-main.yml +28 -0
  4. package/.github/workflows/ci.yml +27 -0
  5. package/.github/workflows/npxhub-publish.yml +170 -0
  6. package/.prettierignore +4 -0
  7. package/.prettierrc.json +7 -0
  8. package/README.md +142 -0
  9. package/SRS.md +448 -0
  10. package/apphosting.staging.yaml +48 -0
  11. package/apphosting.yaml +50 -0
  12. package/bin/evaldoc.cjs +105 -0
  13. package/cors.json +8 -0
  14. package/docs/ARCHITECTURE.md +769 -0
  15. package/docs/ARCHITECTURE_SUMMARY.md +323 -0
  16. package/docs/DEPLOYMENT.md +86 -0
  17. package/docs/DEPLOYMENT_STEPS.md +373 -0
  18. package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
  19. package/docs/DEPLOY_STORAGE_RULES.md +82 -0
  20. package/docs/DOCTOR_TUTORIAL.md +467 -0
  21. package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
  22. package/docs/FIXES_APPLIED.md +128 -0
  23. package/docs/IMPLEMENTATION.md +498 -0
  24. package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
  25. package/docs/LLM-SETUP.md +216 -0
  26. package/docs/MANUAL_TEST_GUIDE.md +327 -0
  27. package/docs/PATIENT_GUIDE.md +451 -0
  28. package/docs/PLATFORM_OVERVIEW.md +194 -0
  29. package/docs/QUICKSTART.md +252 -0
  30. package/docs/QUICK_REFERENCE.md +222 -0
  31. package/docs/READY_TO_TEST.md +383 -0
  32. package/docs/RUN.md +237 -0
  33. package/docs/START_TESTING.md +130 -0
  34. package/docs/STRIPE_CONNECT_SETUP.md +454 -0
  35. package/docs/SYSTEM_READY.md +345 -0
  36. package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
  37. package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
  38. package/docs/firebasefiles.md +21 -0
  39. package/docs/homepage.md +1208 -0
  40. package/docs/payments.md +159 -0
  41. package/eslint.config.js +76 -0
  42. package/firebase.json +27 -0
  43. package/firestore.indexes.json +88 -0
  44. package/firestore.rules +125 -0
  45. package/functions/package-lock.json +2884 -0
  46. package/functions/package.json +20 -0
  47. package/functions/src/analytics.js +101 -0
  48. package/functions/src/audit.js +28 -0
  49. package/functions/src/auth.js +19 -0
  50. package/functions/src/drafts.js +128 -0
  51. package/functions/src/feedback.js +164 -0
  52. package/functions/src/firebase-init.js +29 -0
  53. package/functions/src/i18n.js +134 -0
  54. package/functions/src/limits.js +27 -0
  55. package/functions/src/llm.js +271 -0
  56. package/functions/src/ner.js +162 -0
  57. package/functions/src/notifications.js +273 -0
  58. package/functions/src/orgs.js +127 -0
  59. package/functions/src/patient-api.js +545 -0
  60. package/functions/src/payment.js +830 -0
  61. package/functions/src/safety.js +188 -0
  62. package/functions/src/stripe-config.js +33 -0
  63. package/functions/src/topics.js +94 -0
  64. package/functions/src/triage.js +191 -0
  65. package/functions/src/utils.js +28 -0
  66. package/functions/src/validate.js +177 -0
  67. package/functions/test-firebase.js +36 -0
  68. package/jest.config.js +18 -0
  69. package/package.json +52 -0
  70. package/public/404.html +93 -0
  71. package/public/app/analytics.html +336 -0
  72. package/public/app/analytics.js +177 -0
  73. package/public/app/app.css +1400 -0
  74. package/public/app/app.js +1754 -0
  75. package/public/app/config.js +14 -0
  76. package/public/app/inbox-threads.js +843 -0
  77. package/public/app/inbox.html +598 -0
  78. package/public/app/inbox.js +397 -0
  79. package/public/app/index.html +612 -0
  80. package/public/assets/logo.jpg +0 -0
  81. package/public/chat.html +137 -0
  82. package/public/css/chat.css +870 -0
  83. package/public/css/landing-enhance.css +298 -0
  84. package/public/css/styles.css +1198 -0
  85. package/public/favicon.svg +5 -0
  86. package/public/find-doctor.html +220 -0
  87. package/public/index.html +399 -0
  88. package/public/js/chat-threads.js +513 -0
  89. package/public/js/chat.js +832 -0
  90. package/public/js/search.js +41 -0
  91. package/public/privacy.html +101 -0
  92. package/public/robots.txt +6 -0
  93. package/public/site.webmanifest +12 -0
  94. package/public/terms.html +110 -0
  95. package/scripts/create-test-provider.js +55 -0
  96. package/scripts/deploy-staging.sh +51 -0
  97. package/scripts/fix-provider.js +39 -0
  98. package/scripts/quick-test.sh +37 -0
  99. package/scripts/test-chat.sh +54 -0
  100. package/scripts/test-multi-turn.js +137 -0
  101. package/scripts/test-ner-simple.js +28 -0
  102. package/scripts/test-ner.js +60 -0
  103. package/scripts/test-stripe-connect.js +304 -0
  104. package/scripts/test-webhook-secret.sh +24 -0
  105. package/server.js +3864 -0
  106. package/storage.rules +22 -0
  107. package/test-api.json +1 -0
  108. package/tests/emulator/README.md +38 -0
  109. package/tests/emulator/counters.emulator.test.js +81 -0
  110. package/tests/integration/api-account-selfserve.test.js +312 -0
  111. package/tests/integration/api-admin-digest.test.js +202 -0
  112. package/tests/integration/api-chat-satisfaction.test.js +259 -0
  113. package/tests/integration/api-doctor-drafts.test.js +236 -0
  114. package/tests/integration/api-doctor-inbox.test.js +401 -0
  115. package/tests/integration/api-org.test.js +328 -0
  116. package/tests/integration/api-payment.test.js +664 -0
  117. package/tests/integration/api-provider-settings.test.js +475 -0
  118. package/tests/integration/api-qr.test.js +121 -0
  119. package/tests/integration/api-security.test.js +241 -0
  120. package/tests/integration/api-stripe-search.test.js +303 -0
  121. package/tests/integration/api-webhook-deletion.test.js +231 -0
  122. package/tests/mocks/firebase-admin.js +220 -0
  123. package/tests/unit/analytics.test.js +151 -0
  124. package/tests/unit/audit-bugs.test.js +203 -0
  125. package/tests/unit/chat-ui-logic.test.js +283 -0
  126. package/tests/unit/drafts.test.js +104 -0
  127. package/tests/unit/feedback.test.js +148 -0
  128. package/tests/unit/i18n.test.js +229 -0
  129. package/tests/unit/llm-retry.test.js +122 -0
  130. package/tests/unit/notifications.test.js +155 -0
  131. package/tests/unit/orgs.test.js +328 -0
  132. package/tests/unit/payment.test.js +889 -0
  133. package/tests/unit/safety-edge-cases.test.js +509 -0
  134. package/tests/unit/safety-srs-compliance.test.js +252 -0
  135. package/tests/unit/safety.test.js +291 -0
  136. package/tests/unit/server-helpers.test.js +774 -0
  137. package/tests/unit/topics-limits.test.js +158 -0
  138. package/tests/unit/triage.test.js +152 -0
  139. package/tests/unit/validate.test.js +408 -0
@@ -0,0 +1,231 @@
1
+ process.env.NODE_ENV = 'test';
2
+
3
+ jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
4
+
5
+ const mockStripe = {
6
+ webhooks: {
7
+ constructEvent: jest.fn()
8
+ }
9
+ };
10
+
11
+ jest.mock('stripe', () => jest.fn().mockReturnValue(mockStripe));
12
+ jest.mock('openai', () => jest.fn().mockImplementation(() => ({
13
+ chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: '{"response":"Draft reply"}' } }] }) } }
14
+ })));
15
+ jest.mock('@google/generative-ai', () => ({
16
+ GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
17
+ getGenerativeModel: () => ({
18
+ generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } })
19
+ })
20
+ }))
21
+ }));
22
+ jest.mock('../../functions/src/stripe-config', () => ({ stripe: mockStripe }));
23
+ jest.mock('../../functions/src/payment', () => ({
24
+ createConnectAccount: jest.fn(),
25
+ createAccountLink: jest.fn(),
26
+ refreshAccountStatus: jest.fn(),
27
+ setProviderPricing: jest.fn(),
28
+ createCheckoutSession: jest.fn(),
29
+ createPaymentIntent: jest.fn(),
30
+ createSubscription: jest.fn(),
31
+ cancelSubscription: jest.fn(),
32
+ checkPaymentAccess: jest.fn(),
33
+ getRevenueAnalytics: jest.fn(),
34
+ handleStripeWebhook: jest.fn()
35
+ }));
36
+ process.env.DEEPSEEK_API_KEY = 'test-key';
37
+ process.env.GEMINI_API_KEY = 'test-key';
38
+ process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_secret';
39
+
40
+ const request = require('supertest');
41
+ const admin = require('firebase-admin');
42
+ const { handleStripeWebhook } = require('../../functions/src/payment');
43
+
44
+ admin.auth().verifyIdToken = jest.fn(async (token) => {
45
+ if (token === 'valid-doctor-token') return { uid: 'doctor-uid-1', email: 'doc@test.com' };
46
+ throw new Error('Invalid token');
47
+ });
48
+
49
+ const app = require('../../server');
50
+
51
+ describe('Webhook, Conversation Deletion & Feedback Stats API', () => {
52
+ beforeEach(() => {
53
+ admin.__reset();
54
+ jest.clearAllMocks();
55
+
56
+ admin.__seed('providers', 'doctor-uid-1', {
57
+ ownerUid: 'doctor-uid-1',
58
+ handle: 'drsmith',
59
+ displayName: 'Dr. Smith',
60
+ specialty: 'General',
61
+ status: 'published',
62
+ enabledTopics: ['general_health_info'],
63
+ plan: 'free',
64
+ monthlyChatLimit: 50,
65
+ monthlyChatCount: 5,
66
+ draftsAccepted: 0,
67
+ draftsEdited: 0,
68
+ draftsRejected: 0
69
+ });
70
+ });
71
+
72
+ // ============================================
73
+ // POST /api/payment/webhook
74
+ // ============================================
75
+
76
+ describe('POST /api/payment/webhook', () => {
77
+ it('should process a valid webhook event and call handleStripeWebhook', async () => {
78
+ const fakeEvent = { id: 'evt_123', type: 'payment_intent.succeeded', data: {} };
79
+ mockStripe.webhooks.constructEvent.mockReturnValue(fakeEvent);
80
+ handleStripeWebhook.mockResolvedValue(undefined);
81
+
82
+ const res = await request(app)
83
+ .post('/api/payment/webhook')
84
+ .set('stripe-signature', 'sig_valid')
85
+ .set('Content-Type', 'application/json')
86
+ .send(JSON.stringify({ id: 'evt_123' }));
87
+
88
+ expect(res.status).toBe(200);
89
+ expect(res.body).toEqual({ received: true });
90
+ expect(mockStripe.webhooks.constructEvent).toHaveBeenCalled();
91
+ expect(handleStripeWebhook).toHaveBeenCalledWith(fakeEvent);
92
+ });
93
+
94
+ it('should return 400 when signature verification fails', async () => {
95
+ mockStripe.webhooks.constructEvent.mockImplementation(() => {
96
+ throw new Error('Invalid signature');
97
+ });
98
+
99
+ const res = await request(app)
100
+ .post('/api/payment/webhook')
101
+ .set('stripe-signature', 'sig_invalid')
102
+ .set('Content-Type', 'application/json')
103
+ .send(JSON.stringify({ id: 'evt_bad' }));
104
+
105
+ expect(res.status).toBe(400);
106
+ expect(res.text).toContain('Webhook Error');
107
+ expect(handleStripeWebhook).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('should return 200 even when handleStripeWebhook throws', async () => {
111
+ const fakeEvent = { id: 'evt_456', type: 'invoice.paid', data: {} };
112
+ mockStripe.webhooks.constructEvent.mockReturnValue(fakeEvent);
113
+ handleStripeWebhook.mockRejectedValue(new Error('Handler failed'));
114
+
115
+ const res = await request(app)
116
+ .post('/api/payment/webhook')
117
+ .set('stripe-signature', 'sig_valid')
118
+ .set('Content-Type', 'application/json')
119
+ .send(JSON.stringify({ id: 'evt_456' }));
120
+
121
+ expect(res.status).toBe(200);
122
+ expect(res.body).toEqual({ received: true });
123
+ });
124
+ });
125
+
126
+ // ============================================
127
+ // DELETE /api/conversations/:threadId
128
+ // ============================================
129
+
130
+ describe('DELETE /api/conversations/:threadId', () => {
131
+ it('should delete a thread owned by the authenticated doctor', async () => {
132
+ admin.__seed('threads', 'thread-del-1', {
133
+ doctorId: 'doctor-uid-1',
134
+ subject: 'Test conversation'
135
+ });
136
+
137
+ const res = await request(app)
138
+ .delete('/api/conversations/thread-del-1')
139
+ .set('Authorization', 'Bearer valid-doctor-token');
140
+
141
+ expect(res.status).toBe(200);
142
+ expect(res.body.success).toBe(true);
143
+
144
+ const threads = admin.__getAll('threads');
145
+ expect(threads['thread-del-1']).toBeUndefined();
146
+ });
147
+
148
+ it('should reject deletion of threads owned by other doctors (403)', async () => {
149
+ admin.__seed('threads', 'thread-other-doc', {
150
+ doctorId: 'other-doctor-uid',
151
+ subject: 'Other doctor conversation'
152
+ });
153
+
154
+ const res = await request(app)
155
+ .delete('/api/conversations/thread-other-doc')
156
+ .set('Authorization', 'Bearer valid-doctor-token');
157
+
158
+ expect(res.status).toBe(403);
159
+ expect(res.body.error).toContain('Not authorized');
160
+ });
161
+
162
+ it('should return 404 when thread does not exist', async () => {
163
+ const res = await request(app)
164
+ .delete('/api/conversations/nonexistent-thread')
165
+ .set('Authorization', 'Bearer valid-doctor-token');
166
+
167
+ expect(res.status).toBe(404);
168
+ expect(res.body.error).toContain('Conversation not found');
169
+ });
170
+
171
+ it('should reject unauthenticated requests (401)', async () => {
172
+ admin.__seed('threads', 'thread-del-2', {
173
+ doctorId: 'doctor-uid-1',
174
+ subject: 'Test conversation'
175
+ });
176
+
177
+ const res = await request(app)
178
+ .delete('/api/conversations/thread-del-2');
179
+
180
+ expect(res.status).toBe(401);
181
+ });
182
+ });
183
+
184
+ // ============================================
185
+ // GET /api/doctor/feedback/stats
186
+ // ============================================
187
+
188
+ describe('GET /api/doctor/feedback/stats', () => {
189
+ it('should return feedback stats for authenticated doctor', async () => {
190
+ admin.__seed('providers/doctor-uid-1/doctorExamples', 'ex-1', {
191
+ wasAiDraft: true,
192
+ editDistance: 0.3,
193
+ createdAt: { _seconds: 1000 }
194
+ });
195
+ admin.__seed('providers/doctor-uid-1/doctorExamples', 'ex-2', {
196
+ wasAiDraft: false,
197
+ editDistance: null,
198
+ createdAt: { _seconds: 900 }
199
+ });
200
+
201
+ const res = await request(app)
202
+ .get('/api/doctor/feedback/stats')
203
+ .set('Authorization', 'Bearer valid-doctor-token');
204
+
205
+ expect(res.status).toBe(200);
206
+ expect(res.body.totalExamples).toBe(2);
207
+ expect(res.body.aiDraftBased).toBe(1);
208
+ expect(res.body.manualResponses).toBe(1);
209
+ expect(res.body.avgEditDistance).toBe(0.3);
210
+ });
211
+
212
+ it('should return zero stats when no examples exist', async () => {
213
+ const res = await request(app)
214
+ .get('/api/doctor/feedback/stats')
215
+ .set('Authorization', 'Bearer valid-doctor-token');
216
+
217
+ expect(res.status).toBe(200);
218
+ expect(res.body.totalExamples).toBe(0);
219
+ expect(res.body.aiDraftBased).toBe(0);
220
+ expect(res.body.manualResponses).toBe(0);
221
+ expect(res.body.avgEditDistance).toBeNull();
222
+ });
223
+
224
+ it('should reject unauthenticated requests', async () => {
225
+ const res = await request(app)
226
+ .get('/api/doctor/feedback/stats');
227
+
228
+ expect(res.status).toBe(401);
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Mock firebase-admin for testing.
3
+ *
4
+ * Provides an in-memory Firestore mock that supports:
5
+ * collection / doc / add / set / get / update / where / orderBy / limit / batch
6
+ *
7
+ * Usage:
8
+ * jest.mock('firebase-admin', () => require('./mocks/firebase-admin'));
9
+ * const admin = require('firebase-admin');
10
+ * admin.__collections // access raw data
11
+ * admin.__reset() // clear all data between tests
12
+ */
13
+
14
+ let _collections = {};
15
+ let _autoId = 0;
16
+
17
+ function reset() {
18
+ _collections = {};
19
+ _autoId = 0;
20
+ }
21
+
22
+ function getCollection(name) {
23
+ if (!_collections[name]) _collections[name] = {};
24
+ return _collections[name];
25
+ }
26
+
27
+ function makeDocRef(collectionName, docId) {
28
+ return {
29
+ id: docId,
30
+ get: async () => {
31
+ const col = getCollection(collectionName);
32
+ const data = col[docId];
33
+ return {
34
+ exists: !!data,
35
+ id: docId,
36
+ data: () => data ? { ...data } : undefined,
37
+ ref: makeDocRef(collectionName, docId)
38
+ };
39
+ },
40
+ set: async (data, options) => {
41
+ const col = getCollection(collectionName);
42
+ if (options && options.merge) {
43
+ col[docId] = { ...(col[docId] || {}), ...data };
44
+ } else {
45
+ col[docId] = { ...data };
46
+ }
47
+ },
48
+ update: async (data) => {
49
+ const col = getCollection(collectionName);
50
+ if (!col[docId]) throw new Error(`Document ${collectionName}/${docId} not found`);
51
+ col[docId] = { ...col[docId], ...data };
52
+ },
53
+ delete: async () => {
54
+ const col = getCollection(collectionName);
55
+ delete col[docId];
56
+ },
57
+ collection: (subName) => makeCollectionRef(`${collectionName}/${docId}/${subName}`)
58
+ };
59
+ }
60
+
61
+ function makeQuerySnapshot(collectionName, filterFns) {
62
+ const col = getCollection(collectionName);
63
+ let docs = Object.entries(col).map(([id, data]) => ({
64
+ id,
65
+ data: () => ({ ...data }),
66
+ ref: makeDocRef(collectionName, id)
67
+ }));
68
+
69
+ for (const fn of filterFns) {
70
+ docs = fn(docs);
71
+ }
72
+
73
+ return {
74
+ docs,
75
+ empty: docs.length === 0,
76
+ size: docs.length,
77
+ forEach: (cb) => docs.forEach(cb)
78
+ };
79
+ }
80
+
81
+ function makeCollectionRef(name) {
82
+ let _filters = [];
83
+
84
+ const ref = {
85
+ add: async (data) => {
86
+ const col = getCollection(name);
87
+ const id = `auto_${++_autoId}`;
88
+ col[id] = { ...data };
89
+ return { id, ...makeDocRef(name, id) };
90
+ },
91
+ doc: (id) => makeDocRef(name, id),
92
+ where: (field, op, value) => {
93
+ _filters.push((docs) => docs.filter(d => {
94
+ const val = d.data()[field];
95
+ switch (op) {
96
+ case '==': return val === value;
97
+ case '!=': return val !== value;
98
+ case '>': return val > value;
99
+ case '>=': return val >= value;
100
+ case '<': return val < value;
101
+ case '<=': return val <= value;
102
+ case 'in': return Array.isArray(value) && value.includes(val);
103
+ default: return true;
104
+ }
105
+ }));
106
+ return ref;
107
+ },
108
+ orderBy: (field, direction) => {
109
+ _filters.push((docs) => {
110
+ return [...docs].sort((a, b) => {
111
+ let aVal = a.data()[field];
112
+ let bVal = b.data()[field];
113
+ // Handle Firestore timestamp objects { _seconds: N }
114
+ if (aVal && typeof aVal === 'object' && aVal._seconds != null) aVal = aVal._seconds;
115
+ if (bVal && typeof bVal === 'object' && bVal._seconds != null) bVal = bVal._seconds;
116
+ if (aVal == null && bVal == null) return 0;
117
+ if (aVal == null) return 1;
118
+ if (bVal == null) return -1;
119
+ const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
120
+ return direction === 'desc' ? -cmp : cmp;
121
+ });
122
+ });
123
+ return ref;
124
+ },
125
+ select: (...fields) => {
126
+ return ref;
127
+ },
128
+ limit: (n) => {
129
+ _filters.push((docs) => docs.slice(0, n));
130
+ return ref;
131
+ },
132
+ limitToLast: (n) => {
133
+ _filters.push((docs) => docs.slice(-n));
134
+ return ref;
135
+ },
136
+ startAfter: (doc) => {
137
+ _filters.push((docs) => {
138
+ const idx = docs.findIndex(d => d.id === doc.id);
139
+ return idx >= 0 ? docs.slice(idx + 1) : docs;
140
+ });
141
+ return ref;
142
+ },
143
+ get: async () => makeQuerySnapshot(name, _filters)
144
+ };
145
+
146
+ return ref;
147
+ }
148
+
149
+ function makeBatch() {
150
+ const ops = [];
151
+ return {
152
+ set: (ref, data, options) => ops.push({ type: 'set', ref, data, options }),
153
+ update: (ref, data) => ops.push({ type: 'update', ref, data }),
154
+ delete: (ref) => ops.push({ type: 'delete', ref }),
155
+ commit: async () => {
156
+ for (const op of ops) {
157
+ if (op.type === 'set') await op.ref.set(op.data, op.options);
158
+ else if (op.type === 'update') await op.ref.update(op.data);
159
+ else if (op.type === 'delete') await op.ref.delete();
160
+ }
161
+ }
162
+ };
163
+ }
164
+
165
+ // Singleton instances so test overrides persist across calls
166
+ const _firestoreInstance = {
167
+ collection: (name) => makeCollectionRef(name),
168
+ batch: () => makeBatch(),
169
+ runTransaction: async (fn) => {
170
+ const tx = {
171
+ get: async (ref) => ref.get(),
172
+ set: (ref, data) => ref.set(data),
173
+ update: (ref, data) => ref.update(data)
174
+ };
175
+ return fn(tx);
176
+ }
177
+ };
178
+
179
+ const _authInstance = {
180
+ verifyIdToken: jest.fn(async (token) => {
181
+ if (token === 'valid-token') return { uid: 'test-doctor-uid', email: 'doc@test.com' };
182
+ if (token === 'patient-token') return { uid: 'test-patient-uid', email: 'patient@test.com' };
183
+ throw new Error('Invalid token');
184
+ })
185
+ };
186
+
187
+ const mockAdmin = {
188
+ apps: [{ name: '[DEFAULT]' }],
189
+ initializeApp: jest.fn(),
190
+ credential: {
191
+ cert: jest.fn(() => ({})),
192
+ applicationDefault: jest.fn(() => ({}))
193
+ },
194
+ firestore: () => _firestoreInstance,
195
+ auth: () => _authInstance,
196
+
197
+ // Test helpers
198
+ __collections: _collections,
199
+ __reset: reset,
200
+ __seed: (collectionName, docId, data) => {
201
+ getCollection(collectionName)[docId] = { ...data };
202
+ },
203
+ __getAll: (collectionName) => getCollection(collectionName)
204
+ };
205
+
206
+ // Static FieldValue helpers
207
+ mockAdmin.firestore.FieldValue = {
208
+ serverTimestamp: () => ({ _seconds: Math.floor(Date.now() / 1000), _nanoseconds: 0 }),
209
+ increment: (n) => `__increment_${n}`,
210
+ arrayUnion: (...args) => `__arrayUnion_${args}`,
211
+ arrayRemove: (...args) => `__arrayRemove_${args}`,
212
+ delete: () => '__delete'
213
+ };
214
+
215
+ mockAdmin.firestore.Timestamp = {
216
+ now: () => ({ _seconds: Math.floor(Date.now() / 1000), _nanoseconds: 0 }),
217
+ fromDate: (date) => ({ _seconds: Math.floor(date.getTime() / 1000), _nanoseconds: 0 })
218
+ };
219
+
220
+ module.exports = mockAdmin;
@@ -0,0 +1,151 @@
1
+ jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
2
+
3
+ const admin = require('firebase-admin');
4
+ const { incrementAnalytics, getMonthKey } = require('../../functions/src/analytics');
5
+
6
+ describe('Analytics Module', () => {
7
+ beforeEach(() => {
8
+ admin.__reset();
9
+ });
10
+
11
+ describe('getMonthKey', () => {
12
+ it('should return YYYY-MM format', () => {
13
+ const key = getMonthKey(new Date('2026-06-15T00:00:00Z'));
14
+ expect(key).toBe('2026-06');
15
+ });
16
+
17
+ it('should pad single-digit months', () => {
18
+ const key = getMonthKey(new Date('2026-03-01T00:00:00Z'));
19
+ expect(key).toBe('2026-03');
20
+ });
21
+ });
22
+
23
+ describe('incrementAnalytics', () => {
24
+ it('should increment chat count in daily analytics', async () => {
25
+ await incrementAnalytics({
26
+ providerId: 'test-provider',
27
+ sessionId: 'sess_123',
28
+ escalated: false,
29
+ satisfaction: null,
30
+ topicKey: 'general_health_info',
31
+ message: 'What causes headaches?'
32
+ });
33
+
34
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
35
+ const keys = Object.keys(dailyData);
36
+ expect(keys.length).toBe(1);
37
+ const entry = dailyData[keys[0]];
38
+ expect(entry.chatCount).toBe('__increment_1');
39
+ });
40
+
41
+ it('should increment escalation count when escalated', async () => {
42
+ await incrementAnalytics({
43
+ providerId: 'test-provider',
44
+ sessionId: 'sess_456',
45
+ escalated: true,
46
+ satisfaction: null,
47
+ topicKey: 'general_health_info',
48
+ message: 'I have chest pain'
49
+ });
50
+
51
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
52
+ const entry = dailyData[Object.keys(dailyData)[0]];
53
+ expect(entry.escalationCount).toBe('__increment_1');
54
+
55
+ const providerData = admin.__getAll('providers');
56
+ expect(providerData['test-provider'].totalEscalationCount).toBe('__increment_1');
57
+ });
58
+
59
+ it('should not set escalation count when not escalated', async () => {
60
+ await incrementAnalytics({
61
+ providerId: 'test-provider',
62
+ sessionId: 'sess_789',
63
+ escalated: false,
64
+ satisfaction: null,
65
+ topicKey: 'general_health_info',
66
+ message: 'Hello'
67
+ });
68
+
69
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
70
+ const entry = dailyData[Object.keys(dailyData)[0]];
71
+ expect(entry.escalationCount).toBeUndefined();
72
+ });
73
+
74
+ it('should increment satisfaction sum and count when provided', async () => {
75
+ await incrementAnalytics({
76
+ providerId: 'test-provider',
77
+ sessionId: 'sess_789',
78
+ escalated: false,
79
+ satisfaction: 4,
80
+ topicKey: 'medication_info_safety',
81
+ message: 'Thank you'
82
+ });
83
+
84
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
85
+ const entry = dailyData[Object.keys(dailyData)[0]];
86
+ expect(entry.satisfactionSum).toBe('__increment_4');
87
+ expect(entry.satisfactionCount).toBe('__increment_1');
88
+ });
89
+
90
+ it('should not set satisfaction fields when null', async () => {
91
+ await incrementAnalytics({
92
+ providerId: 'test-provider',
93
+ sessionId: 'sess_null',
94
+ escalated: false,
95
+ satisfaction: null,
96
+ topicKey: null,
97
+ message: ''
98
+ });
99
+
100
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
101
+ const entry = dailyData[Object.keys(dailyData)[0]];
102
+ expect(entry.satisfactionSum).toBeUndefined();
103
+ expect(entry.satisfactionCount).toBeUndefined();
104
+ });
105
+
106
+ it('should track topic counts', async () => {
107
+ await incrementAnalytics({
108
+ providerId: 'test-provider',
109
+ sessionId: 'sess_t',
110
+ escalated: false,
111
+ satisfaction: null,
112
+ topicKey: 'clinic_services_hours',
113
+ message: 'What are your hours?'
114
+ });
115
+
116
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
117
+ const entry = dailyData[Object.keys(dailyData)[0]];
118
+ expect(entry['topicCounts.clinic_services_hours']).toBe('__increment_1');
119
+ });
120
+
121
+ it('should track session for unique session counting', async () => {
122
+ await incrementAnalytics({
123
+ providerId: 'test-provider',
124
+ sessionId: 'sess_unique',
125
+ escalated: false,
126
+ satisfaction: null,
127
+ topicKey: 'general_health_info',
128
+ message: 'Hello'
129
+ });
130
+
131
+ const dailyData = admin.__getAll('providers/test-provider/analyticsDaily');
132
+ const entry = dailyData[Object.keys(dailyData)[0]];
133
+ expect(entry['sessions.sess_unique']).toBe(true);
134
+ });
135
+
136
+ it('should increment provider-level monthly and total chat counts', async () => {
137
+ await incrementAnalytics({
138
+ providerId: 'test-provider',
139
+ sessionId: 'sess_p',
140
+ escalated: false,
141
+ satisfaction: null,
142
+ topicKey: 'general_health_info',
143
+ message: 'Test'
144
+ });
145
+
146
+ const providerData = admin.__getAll('providers');
147
+ expect(providerData['test-provider'].monthlyChatCount).toBe('__increment_1');
148
+ expect(providerData['test-provider'].totalChatCount).toBe('__increment_1');
149
+ });
150
+ });
151
+ });