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,774 @@
1
+ /**
2
+ * Server helper function unit tests
3
+ *
4
+ * Tests pure functions from server.js (inlined for isolation):
5
+ * - normalizeHandle: handle normalization and sanitization
6
+ * - sanitizeTopics: topic array validation
7
+ * - HEX_COLOR_REGEX: hex color validation
8
+ * - stripHtml: HTML tag stripping
9
+ * - sanitizeContactInfo: contact info sanitization
10
+ * - rateLimit: rate limiting logic (middleware)
11
+ * - Pagination parameter parsing
12
+ * - HANDLE_REGEX: handle format validation
13
+ * - sanitizeProviderUpdate: compound sanitization
14
+ */
15
+
16
+ // Inline copies of pure functions from server.js (same pattern as chat-ui-logic.test.js)
17
+
18
+ function normalizeHandle(handle) {
19
+ return String(handle || '')
20
+ .trim()
21
+ .toLowerCase()
22
+ .replace(/\s+/g, '-')
23
+ .replace(/[^a-z0-9-]/g, '');
24
+ }
25
+
26
+ const TOPIC_KEYS = [
27
+ 'medication_info_safety',
28
+ 'appointments_preparation',
29
+ 'general_health_info',
30
+ 'clinic_services_hours',
31
+ 'insurance_billing_faq',
32
+ 'post_procedure_guidance'
33
+ ];
34
+
35
+ function sanitizeTopics(topics) {
36
+ if (!Array.isArray(topics)) return null;
37
+ const filtered = topics.filter((topic) => TOPIC_KEYS.includes(topic));
38
+ return filtered.length ? filtered : null;
39
+ }
40
+
41
+ const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
42
+
43
+ function stripHtml(str) {
44
+ return String(str).replace(/<[^>]*>/g, '').trim();
45
+ }
46
+
47
+ function sanitizeContactInfo(ci) {
48
+ if (!ci || typeof ci !== 'object') return null;
49
+ const clean = {};
50
+ if (ci.phone) clean.phone = stripHtml(String(ci.phone)).replace(/[^0-9+\-() .ext]/gi, '').substring(0, 30);
51
+ if (ci.email) {
52
+ const email = String(ci.email).trim().toLowerCase();
53
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) clean.email = email;
54
+ }
55
+ if (ci.address) clean.address = stripHtml(ci.address).substring(0, 200);
56
+ return Object.keys(clean).length ? clean : null;
57
+ }
58
+
59
+ const HANDLE_REGEX = /^[a-z0-9][a-z0-9-]{2,29}$/;
60
+ const RESERVED_HANDLES = new Set(['app', 'api', 'admin', 'static', 'public', 'functions', 'health']);
61
+
62
+ function sanitizeProviderUpdate(body) {
63
+ const update = {};
64
+ if (body.displayName !== undefined) {
65
+ const name = stripHtml(body.displayName).substring(0, 100);
66
+ if (name) update.displayName = name;
67
+ }
68
+ if (body.specialty !== undefined) {
69
+ update.specialty = stripHtml(body.specialty).substring(0, 100);
70
+ }
71
+ if (body.country !== undefined) update.country = stripHtml(body.country).substring(0, 60);
72
+ if (body.locale !== undefined) update.locale = String(body.locale).trim().substring(0, 10);
73
+ if (body.photoUrl !== undefined) update.photoUrl = body.photoUrl ? String(body.photoUrl).trim() : null;
74
+ if (body.logoUrl !== undefined) update.logoUrl = body.logoUrl ? String(body.logoUrl).trim() : null;
75
+ if (body.brandPrimaryColor !== undefined) {
76
+ const c = String(body.brandPrimaryColor).trim();
77
+ if (HEX_COLOR_REGEX.test(c)) update.brandPrimaryColor = c;
78
+ }
79
+ if (body.brandSecondaryColor !== undefined) {
80
+ const c = String(body.brandSecondaryColor).trim();
81
+ if (HEX_COLOR_REGEX.test(c)) update.brandSecondaryColor = c;
82
+ }
83
+ if (body.welcomeMessage !== undefined) update.welcomeMessage = stripHtml(body.welcomeMessage).substring(0, 500);
84
+ if (body.contactInfo !== undefined) {
85
+ update.contactInfo = body.contactInfo === null ? null : sanitizeContactInfo(body.contactInfo);
86
+ }
87
+ if (body.businessHours !== undefined && (body.businessHours === null || typeof body.businessHours === 'object')) {
88
+ update.businessHours = body.businessHours;
89
+ }
90
+
91
+ const topics = sanitizeTopics(body.enabledTopics);
92
+ if (topics) update.enabledTopics = topics;
93
+
94
+ if (body.status && ['draft', 'preview', 'published', 'suspended'].includes(body.status)) {
95
+ update.status = body.status;
96
+ }
97
+
98
+ return update;
99
+ }
100
+
101
+ // Rate limit factory (inlined from server.js, adapted for unit testing)
102
+ function createRateLimiter({ maxRequests = 30, windowMs = 60000 } = {}) {
103
+ const store = new Map();
104
+ return {
105
+ store,
106
+ middleware: (req, res, next) => {
107
+ const ip = req.ip || req.connection.remoteAddress || 'unknown';
108
+ const key = `${ip}:${req.path}`;
109
+ const now = Date.now();
110
+ let entry = store.get(key);
111
+
112
+ if (!entry || now - entry.windowStart > windowMs) {
113
+ entry = { windowStart: now, count: 0 };
114
+ }
115
+
116
+ entry.count++;
117
+ store.set(key, entry);
118
+
119
+ if (entry.count > maxRequests) {
120
+ return res.status(429).json({ error: 'Too many requests. Please wait and try again.' });
121
+ }
122
+
123
+ res.setHeader('X-RateLimit-Limit', String(maxRequests));
124
+ res.setHeader('X-RateLimit-Remaining', String(Math.max(0, maxRequests - entry.count)));
125
+ next();
126
+ }
127
+ };
128
+ }
129
+
130
+ // Pagination parameter parsing (inlined from server.js endpoint logic)
131
+ function parsePaginationParams(query, { defaultLimit = 30, maxLimit = 50 } = {}) {
132
+ const limit = Math.min(parseInt(query.limit, 10) || defaultLimit, maxLimit);
133
+ const offset = Math.max(parseInt(query.offset, 10) || 0, 0);
134
+ return { limit, offset };
135
+ }
136
+
137
+
138
+ describe('Server Helpers', () => {
139
+
140
+ // ─── normalizeHandle ───────────────────────────────────────────
141
+ describe('normalizeHandle', () => {
142
+ it('should lowercase uppercase input', () => {
143
+ expect(normalizeHandle('DrSmith')).toBe('drsmith');
144
+ });
145
+
146
+ it('should replace spaces with hyphens', () => {
147
+ expect(normalizeHandle('dr smith clinic')).toBe('dr-smith-clinic');
148
+ });
149
+
150
+ it('should collapse multiple spaces into one hyphen', () => {
151
+ expect(normalizeHandle('dr smith')).toBe('dr-smith');
152
+ });
153
+
154
+ it('should remove special characters', () => {
155
+ expect(normalizeHandle('dr.smith@clinic!')).toBe('drsmithclinic');
156
+ });
157
+
158
+ it('should handle empty string', () => {
159
+ expect(normalizeHandle('')).toBe('');
160
+ });
161
+
162
+ it('should handle null', () => {
163
+ expect(normalizeHandle(null)).toBe('');
164
+ });
165
+
166
+ it('should handle undefined', () => {
167
+ expect(normalizeHandle(undefined)).toBe('');
168
+ });
169
+
170
+ it('should trim leading and trailing whitespace', () => {
171
+ expect(normalizeHandle(' drsmith ')).toBe('drsmith');
172
+ });
173
+
174
+ it('should handle numeric input', () => {
175
+ expect(normalizeHandle(12345)).toBe('12345');
176
+ });
177
+
178
+ it('should strip unicode characters', () => {
179
+ expect(normalizeHandle('dr-müller')).toBe('dr-mller');
180
+ });
181
+
182
+ it('should handle string with only special characters', () => {
183
+ expect(normalizeHandle('!@#$%^&*()')).toBe('');
184
+ });
185
+
186
+ it('should preserve hyphens', () => {
187
+ expect(normalizeHandle('my-clinic')).toBe('my-clinic');
188
+ });
189
+ });
190
+
191
+ // ─── sanitizeTopics ────────────────────────────────────────────
192
+ describe('sanitizeTopics', () => {
193
+ it('should return valid topics unchanged', () => {
194
+ const input = ['medication_info_safety', 'general_health_info'];
195
+ expect(sanitizeTopics(input)).toEqual(input);
196
+ });
197
+
198
+ it('should filter out invalid topics', () => {
199
+ const input = ['medication_info_safety', 'fake_topic', 'general_health_info'];
200
+ expect(sanitizeTopics(input)).toEqual(['medication_info_safety', 'general_health_info']);
201
+ });
202
+
203
+ it('should return null for empty array', () => {
204
+ expect(sanitizeTopics([])).toBeNull();
205
+ });
206
+
207
+ it('should return null for non-array input (string)', () => {
208
+ expect(sanitizeTopics('medication_info_safety')).toBeNull();
209
+ });
210
+
211
+ it('should return null for non-array input (number)', () => {
212
+ expect(sanitizeTopics(42)).toBeNull();
213
+ });
214
+
215
+ it('should return null for null input', () => {
216
+ expect(sanitizeTopics(null)).toBeNull();
217
+ });
218
+
219
+ it('should return null for undefined input', () => {
220
+ expect(sanitizeTopics(undefined)).toBeNull();
221
+ });
222
+
223
+ it('should return null when all topics are invalid', () => {
224
+ expect(sanitizeTopics(['bogus', 'invalid', 'nope'])).toBeNull();
225
+ });
226
+
227
+ it('should accept all six valid topics', () => {
228
+ const result = sanitizeTopics(TOPIC_KEYS.slice());
229
+ expect(result).toEqual(TOPIC_KEYS);
230
+ });
231
+
232
+ it('should return null for object input', () => {
233
+ expect(sanitizeTopics({ topic: 'medication_info_safety' })).toBeNull();
234
+ });
235
+ });
236
+
237
+ // ─── HEX_COLOR_REGEX ──────────────────────────────────────────
238
+ describe('HEX_COLOR_REGEX', () => {
239
+ it('should match valid 6-digit hex color (lowercase)', () => {
240
+ expect(HEX_COLOR_REGEX.test('#aabbcc')).toBe(true);
241
+ });
242
+
243
+ it('should match valid 6-digit hex color (uppercase)', () => {
244
+ expect(HEX_COLOR_REGEX.test('#AABBCC')).toBe(true);
245
+ });
246
+
247
+ it('should match valid 6-digit hex color (mixed case)', () => {
248
+ expect(HEX_COLOR_REGEX.test('#aAbBcC')).toBe(true);
249
+ });
250
+
251
+ it('should match valid black color', () => {
252
+ expect(HEX_COLOR_REGEX.test('#000000')).toBe(true);
253
+ });
254
+
255
+ it('should match valid white color', () => {
256
+ expect(HEX_COLOR_REGEX.test('#ffffff')).toBe(true);
257
+ });
258
+
259
+ it('should reject 3-digit hex shorthand', () => {
260
+ expect(HEX_COLOR_REGEX.test('#abc')).toBe(false);
261
+ });
262
+
263
+ it('should reject 8-digit hex (alpha channel)', () => {
264
+ expect(HEX_COLOR_REGEX.test('#aabbccdd')).toBe(false);
265
+ });
266
+
267
+ it('should reject missing hash prefix', () => {
268
+ expect(HEX_COLOR_REGEX.test('aabbcc')).toBe(false);
269
+ });
270
+
271
+ it('should reject invalid hex characters', () => {
272
+ expect(HEX_COLOR_REGEX.test('#gghhii')).toBe(false);
273
+ });
274
+
275
+ it('should reject empty string', () => {
276
+ expect(HEX_COLOR_REGEX.test('')).toBe(false);
277
+ });
278
+
279
+ it('should reject color name', () => {
280
+ expect(HEX_COLOR_REGEX.test('red')).toBe(false);
281
+ });
282
+
283
+ it('should reject rgb notation', () => {
284
+ expect(HEX_COLOR_REGEX.test('rgb(0,0,0)')).toBe(false);
285
+ });
286
+ });
287
+
288
+ // ─── stripHtml ─────────────────────────────────────────────────
289
+ describe('stripHtml', () => {
290
+ it('should remove simple HTML tags', () => {
291
+ expect(stripHtml('<b>bold</b>')).toBe('bold');
292
+ });
293
+
294
+ it('should remove nested HTML tags', () => {
295
+ expect(stripHtml('<div><span>text</span></div>')).toBe('text');
296
+ });
297
+
298
+ it('should handle self-closing tags', () => {
299
+ expect(stripHtml('before<br/>after')).toBe('beforeafter');
300
+ });
301
+
302
+ it('should remove script tags and content markers', () => {
303
+ expect(stripHtml('<script>alert("xss")</script>')).toBe('alert("xss")');
304
+ });
305
+
306
+ it('should remove tags with attributes', () => {
307
+ expect(stripHtml('<a href="http://evil.com">click</a>')).toBe('click');
308
+ });
309
+
310
+ it('should handle empty string', () => {
311
+ expect(stripHtml('')).toBe('');
312
+ });
313
+
314
+ it('should handle string with no HTML', () => {
315
+ expect(stripHtml('plain text')).toBe('plain text');
316
+ });
317
+
318
+ it('should trim whitespace from result', () => {
319
+ expect(stripHtml(' <b>text</b> ')).toBe('text');
320
+ });
321
+
322
+ it('should handle non-string input (number)', () => {
323
+ expect(stripHtml(42)).toBe('42');
324
+ });
325
+
326
+ it('should handle img tag with onerror XSS payload', () => {
327
+ expect(stripHtml('<img src=x onerror=alert(1)>')).toBe('');
328
+ });
329
+
330
+ it('should strip event handler attributes in tags', () => {
331
+ expect(stripHtml('<div onmouseover="steal()">safe</div>')).toBe('safe');
332
+ });
333
+
334
+ it('should handle multiple tags in sequence', () => {
335
+ expect(stripHtml('<b>a</b><i>b</i><u>c</u>')).toBe('abc');
336
+ });
337
+ });
338
+
339
+ // ─── sanitizeContactInfo ───────────────────────────────────────
340
+ describe('sanitizeContactInfo', () => {
341
+ it('should sanitize valid phone number', () => {
342
+ const result = sanitizeContactInfo({ phone: '+1 (555) 123-4567' });
343
+ expect(result.phone).toBe('+1 (555) 123-4567');
344
+ });
345
+
346
+ it('should strip HTML from phone number', () => {
347
+ const result = sanitizeContactInfo({ phone: '<script>alert(1)</script>555-1234' });
348
+ expect(result.phone).not.toContain('<script>');
349
+ expect(result.phone).toContain('555-1234');
350
+ });
351
+
352
+ it('should strip non-phone characters from phone', () => {
353
+ const result = sanitizeContactInfo({ phone: 'CALL: 555-1234; DROP TABLE' });
354
+ expect(result.phone).not.toContain(';');
355
+ expect(result.phone).not.toContain('DROP');
356
+ });
357
+
358
+ it('should truncate phone to 30 characters', () => {
359
+ const result = sanitizeContactInfo({ phone: '1'.repeat(50) });
360
+ expect(result.phone.length).toBeLessThanOrEqual(30);
361
+ });
362
+
363
+ it('should validate and lowercase email', () => {
364
+ const result = sanitizeContactInfo({ email: 'Doctor@Hospital.COM' });
365
+ expect(result.email).toBe('doctor@hospital.com');
366
+ });
367
+
368
+ it('should reject invalid email (no @)', () => {
369
+ const result = sanitizeContactInfo({ email: 'not-an-email' });
370
+ expect(result).toBeNull();
371
+ });
372
+
373
+ it('should reject email with spaces', () => {
374
+ const result = sanitizeContactInfo({ email: 'user @domain.com' });
375
+ expect(result).toBeNull();
376
+ });
377
+
378
+ it('should sanitize address by stripping HTML', () => {
379
+ const result = sanitizeContactInfo({ address: '<b>123 Main St</b>' });
380
+ expect(result.address).toBe('123 Main St');
381
+ });
382
+
383
+ it('should truncate address to 200 characters', () => {
384
+ const result = sanitizeContactInfo({ address: 'A'.repeat(300) });
385
+ expect(result.address.length).toBeLessThanOrEqual(200);
386
+ });
387
+
388
+ it('should return null for null input', () => {
389
+ expect(sanitizeContactInfo(null)).toBeNull();
390
+ });
391
+
392
+ it('should return null for non-object input', () => {
393
+ expect(sanitizeContactInfo('string')).toBeNull();
394
+ });
395
+
396
+ it('should return null for empty object', () => {
397
+ expect(sanitizeContactInfo({})).toBeNull();
398
+ });
399
+
400
+ it('should return null for object with only invalid email', () => {
401
+ expect(sanitizeContactInfo({ email: 'bad' })).toBeNull();
402
+ });
403
+
404
+ it('should handle all three fields together', () => {
405
+ const result = sanitizeContactInfo({
406
+ phone: '555-0100',
407
+ email: 'doc@clinic.com',
408
+ address: '456 Oak Ave'
409
+ });
410
+ expect(result.phone).toBe('555-0100');
411
+ expect(result.email).toBe('doc@clinic.com');
412
+ expect(result.address).toBe('456 Oak Ave');
413
+ });
414
+
415
+ it('should allow phone number with ext', () => {
416
+ const result = sanitizeContactInfo({ phone: '555-0100 ext 42' });
417
+ expect(result.phone).toContain('ext');
418
+ });
419
+ });
420
+
421
+ // ─── HANDLE_REGEX ──────────────────────────────────────────────
422
+ describe('HANDLE_REGEX', () => {
423
+ it('should accept valid lowercase alphanumeric handle', () => {
424
+ expect(HANDLE_REGEX.test('drsmith')).toBe(true);
425
+ });
426
+
427
+ it('should accept handle with hyphens', () => {
428
+ expect(HANDLE_REGEX.test('dr-smith-clinic')).toBe(true);
429
+ });
430
+
431
+ it('should accept handle starting with a digit', () => {
432
+ expect(HANDLE_REGEX.test('1clinic')).toBe(true);
433
+ });
434
+
435
+ it('should require minimum 3 characters', () => {
436
+ expect(HANDLE_REGEX.test('ab')).toBe(false);
437
+ });
438
+
439
+ it('should accept exactly 3 characters', () => {
440
+ expect(HANDLE_REGEX.test('abc')).toBe(true);
441
+ });
442
+
443
+ it('should accept exactly 30 characters', () => {
444
+ expect(HANDLE_REGEX.test('a' + 'b'.repeat(29))).toBe(true);
445
+ });
446
+
447
+ it('should reject more than 30 characters', () => {
448
+ expect(HANDLE_REGEX.test('a' + 'b'.repeat(30))).toBe(false);
449
+ });
450
+
451
+ it('should reject handle starting with a hyphen', () => {
452
+ expect(HANDLE_REGEX.test('-drsmith')).toBe(false);
453
+ });
454
+
455
+ it('should reject uppercase characters', () => {
456
+ expect(HANDLE_REGEX.test('DrSmith')).toBe(false);
457
+ });
458
+
459
+ it('should reject special characters', () => {
460
+ expect(HANDLE_REGEX.test('dr_smith')).toBe(false);
461
+ });
462
+
463
+ it('should reject empty string', () => {
464
+ expect(HANDLE_REGEX.test('')).toBe(false);
465
+ });
466
+ });
467
+
468
+ // ─── RESERVED_HANDLES ─────────────────────────────────────────
469
+ describe('RESERVED_HANDLES', () => {
470
+ it('should include "api"', () => {
471
+ expect(RESERVED_HANDLES.has('api')).toBe(true);
472
+ });
473
+
474
+ it('should include "admin"', () => {
475
+ expect(RESERVED_HANDLES.has('admin')).toBe(true);
476
+ });
477
+
478
+ it('should include "health"', () => {
479
+ expect(RESERVED_HANDLES.has('health')).toBe(true);
480
+ });
481
+
482
+ it('should not include arbitrary strings', () => {
483
+ expect(RESERVED_HANDLES.has('drsmith')).toBe(false);
484
+ });
485
+
486
+ it('should have 7 reserved handles', () => {
487
+ expect(RESERVED_HANDLES.size).toBe(7);
488
+ });
489
+ });
490
+
491
+ // ─── Rate Limiting ────────────────────────────────────────────
492
+ describe('rateLimit', () => {
493
+ function makeMockReqRes(ip, path) {
494
+ const headers = {};
495
+ return {
496
+ req: { ip, path, connection: { remoteAddress: ip } },
497
+ res: {
498
+ statusCode: null,
499
+ headers,
500
+ status(code) { this.statusCode = code; return this; },
501
+ json(data) { this.body = data; return this; },
502
+ setHeader(k, v) { headers[k] = v; }
503
+ }
504
+ };
505
+ }
506
+
507
+ it('should allow requests under the limit', () => {
508
+ const { middleware } = createRateLimiter({ maxRequests: 5, windowMs: 60000 });
509
+ const { req, res } = makeMockReqRes('1.2.3.4', '/api/test');
510
+ let nextCalled = false;
511
+ middleware(req, res, () => { nextCalled = true; });
512
+ expect(nextCalled).toBe(true);
513
+ });
514
+
515
+ it('should set rate limit headers', () => {
516
+ const { middleware } = createRateLimiter({ maxRequests: 10, windowMs: 60000 });
517
+ const { req, res } = makeMockReqRes('1.2.3.4', '/api/test');
518
+ middleware(req, res, () => {});
519
+ expect(res.headers['X-RateLimit-Limit']).toBe('10');
520
+ expect(res.headers['X-RateLimit-Remaining']).toBe('9');
521
+ });
522
+
523
+ it('should block requests over the limit', () => {
524
+ const { middleware } = createRateLimiter({ maxRequests: 3, windowMs: 60000 });
525
+ const { req, res } = makeMockReqRes('5.6.7.8', '/api/chat');
526
+ const noop = () => {};
527
+ middleware(req, res, noop);
528
+ middleware(req, res, noop);
529
+ middleware(req, res, noop);
530
+ // 4th request exceeds limit of 3
531
+ let nextCalled = false;
532
+ middleware(req, res, () => { nextCalled = true; });
533
+ expect(nextCalled).toBe(false);
534
+ expect(res.statusCode).toBe(429);
535
+ expect(res.body.error).toContain('Too many requests');
536
+ });
537
+
538
+ it('should track different IPs separately', () => {
539
+ const { middleware } = createRateLimiter({ maxRequests: 2, windowMs: 60000 });
540
+ const { req: req1, res: res1 } = makeMockReqRes('10.0.0.1', '/api/test');
541
+ const { req: req2, res: res2 } = makeMockReqRes('10.0.0.2', '/api/test');
542
+ const noop = () => {};
543
+ middleware(req1, res1, noop);
544
+ middleware(req1, res1, noop);
545
+ // IP1 is at limit, IP2 should still be fine
546
+ let nextCalled = false;
547
+ middleware(req2, res2, () => { nextCalled = true; });
548
+ expect(nextCalled).toBe(true);
549
+ });
550
+
551
+ it('should track different paths separately', () => {
552
+ const { middleware } = createRateLimiter({ maxRequests: 1, windowMs: 60000 });
553
+ const { req: req1, res: res1 } = makeMockReqRes('10.0.0.1', '/api/chat');
554
+ const { req: req2, res: res2 } = makeMockReqRes('10.0.0.1', '/api/provider');
555
+ const noop = () => {};
556
+ middleware(req1, res1, noop);
557
+ // Same IP, different path should still work
558
+ let nextCalled = false;
559
+ middleware(req2, res2, () => { nextCalled = true; });
560
+ expect(nextCalled).toBe(true);
561
+ });
562
+
563
+ it('should reset count after window expires', () => {
564
+ const { middleware, store } = createRateLimiter({ maxRequests: 2, windowMs: 1000 });
565
+ const { req, res } = makeMockReqRes('10.0.0.3', '/api/test');
566
+ const noop = () => {};
567
+ middleware(req, res, noop);
568
+ middleware(req, res, noop);
569
+ // Manually expire the window
570
+ const key = '10.0.0.3:/api/test';
571
+ const entry = store.get(key);
572
+ entry.windowStart = Date.now() - 2000; // expired
573
+ store.set(key, entry);
574
+ let nextCalled = false;
575
+ middleware(req, res, () => { nextCalled = true; });
576
+ expect(nextCalled).toBe(true);
577
+ });
578
+
579
+ it('should show remaining count decreasing', () => {
580
+ const { middleware } = createRateLimiter({ maxRequests: 5, windowMs: 60000 });
581
+ const { req, res } = makeMockReqRes('10.0.0.4', '/api/test');
582
+ middleware(req, res, () => {});
583
+ expect(res.headers['X-RateLimit-Remaining']).toBe('4');
584
+ middleware(req, res, () => {});
585
+ expect(res.headers['X-RateLimit-Remaining']).toBe('3');
586
+ middleware(req, res, () => {});
587
+ expect(res.headers['X-RateLimit-Remaining']).toBe('2');
588
+ });
589
+
590
+ it('should use default maxRequests of 30 when no options given', () => {
591
+ const { middleware } = createRateLimiter();
592
+ const { req, res } = makeMockReqRes('10.0.0.5', '/default');
593
+ middleware(req, res, () => {});
594
+ expect(res.headers['X-RateLimit-Limit']).toBe('30');
595
+ });
596
+
597
+ it('should fallback to "unknown" when no IP available', () => {
598
+ const { middleware, store } = createRateLimiter({ maxRequests: 5, windowMs: 60000 });
599
+ const req = { ip: undefined, path: '/test', connection: { remoteAddress: undefined } };
600
+ const headers = {};
601
+ const res = {
602
+ setHeader(k, v) { headers[k] = v; },
603
+ status() { return this; },
604
+ json() { return this; }
605
+ };
606
+ middleware(req, res, () => {});
607
+ expect(store.has('unknown:/test')).toBe(true);
608
+ });
609
+ });
610
+
611
+ // ─── Pagination Parameter Parsing ─────────────────────────────
612
+ describe('Pagination parameter parsing', () => {
613
+ it('should use default limit when not provided', () => {
614
+ const { limit } = parsePaginationParams({});
615
+ expect(limit).toBe(30);
616
+ });
617
+
618
+ it('should use default offset of 0 when not provided', () => {
619
+ const { offset } = parsePaginationParams({});
620
+ expect(offset).toBe(0);
621
+ });
622
+
623
+ it('should parse valid limit', () => {
624
+ const { limit } = parsePaginationParams({ limit: '25' });
625
+ expect(limit).toBe(25);
626
+ });
627
+
628
+ it('should cap limit at maxLimit', () => {
629
+ const { limit } = parsePaginationParams({ limit: '200' });
630
+ expect(limit).toBe(50);
631
+ });
632
+
633
+ it('should clamp negative offset to 0', () => {
634
+ const { offset } = parsePaginationParams({ offset: '-5' });
635
+ expect(offset).toBe(0);
636
+ });
637
+
638
+ it('should handle NaN limit gracefully', () => {
639
+ const { limit } = parsePaginationParams({ limit: 'abc' });
640
+ expect(limit).toBe(30);
641
+ });
642
+
643
+ it('should handle NaN offset gracefully', () => {
644
+ const { offset } = parsePaginationParams({ offset: 'xyz' });
645
+ expect(offset).toBe(0);
646
+ });
647
+
648
+ it('should respect custom defaultLimit', () => {
649
+ const { limit } = parsePaginationParams({}, { defaultLimit: 50, maxLimit: 100 });
650
+ expect(limit).toBe(50);
651
+ });
652
+
653
+ it('should respect custom maxLimit', () => {
654
+ const { limit } = parsePaginationParams({ limit: '200' }, { defaultLimit: 50, maxLimit: 100 });
655
+ expect(limit).toBe(100);
656
+ });
657
+
658
+ it('should accept limit=0 as falsy and use default', () => {
659
+ const { limit } = parsePaginationParams({ limit: '0' });
660
+ expect(limit).toBe(30);
661
+ });
662
+ });
663
+
664
+ // ─── sanitizeProviderUpdate ────────────────────────────────────
665
+ describe('sanitizeProviderUpdate', () => {
666
+ it('should strip HTML from displayName', () => {
667
+ const result = sanitizeProviderUpdate({ displayName: '<b>Dr Evil</b>' });
668
+ expect(result.displayName).toBe('Dr Evil');
669
+ });
670
+
671
+ it('should truncate displayName to 100 characters', () => {
672
+ const result = sanitizeProviderUpdate({ displayName: 'A'.repeat(200) });
673
+ expect(result.displayName.length).toBeLessThanOrEqual(100);
674
+ });
675
+
676
+ it('should not set displayName if it becomes empty after stripping', () => {
677
+ const result = sanitizeProviderUpdate({ displayName: '<script></script>' });
678
+ expect(result.displayName).toBeUndefined();
679
+ });
680
+
681
+ it('should strip HTML from specialty', () => {
682
+ const result = sanitizeProviderUpdate({ specialty: '<img src=x>Cardiology' });
683
+ expect(result.specialty).toBe('Cardiology');
684
+ });
685
+
686
+ it('should accept valid hex brand color', () => {
687
+ const result = sanitizeProviderUpdate({ brandPrimaryColor: '#ff5500' });
688
+ expect(result.brandPrimaryColor).toBe('#ff5500');
689
+ });
690
+
691
+ it('should reject invalid hex brand color', () => {
692
+ const result = sanitizeProviderUpdate({ brandPrimaryColor: 'red' });
693
+ expect(result.brandPrimaryColor).toBeUndefined();
694
+ });
695
+
696
+ it('should accept valid status values', () => {
697
+ expect(sanitizeProviderUpdate({ status: 'draft' }).status).toBe('draft');
698
+ expect(sanitizeProviderUpdate({ status: 'preview' }).status).toBe('preview');
699
+ expect(sanitizeProviderUpdate({ status: 'published' }).status).toBe('published');
700
+ expect(sanitizeProviderUpdate({ status: 'suspended' }).status).toBe('suspended');
701
+ });
702
+
703
+ it('should reject invalid status values', () => {
704
+ const result = sanitizeProviderUpdate({ status: 'hacked' });
705
+ expect(result.status).toBeUndefined();
706
+ });
707
+
708
+ it('should set contactInfo to null when explicitly null', () => {
709
+ const result = sanitizeProviderUpdate({ contactInfo: null });
710
+ expect(result.contactInfo).toBeNull();
711
+ });
712
+
713
+ it('should sanitize contactInfo object', () => {
714
+ const result = sanitizeProviderUpdate({
715
+ contactInfo: { email: 'Doc@Test.COM', phone: '<b>555</b>' }
716
+ });
717
+ expect(result.contactInfo.email).toBe('doc@test.com');
718
+ expect(result.contactInfo.phone).toBe('555');
719
+ });
720
+
721
+ it('should sanitize enabledTopics', () => {
722
+ const result = sanitizeProviderUpdate({
723
+ enabledTopics: ['medication_info_safety', 'bogus']
724
+ });
725
+ expect(result.enabledTopics).toEqual(['medication_info_safety']);
726
+ });
727
+
728
+ it('should truncate welcomeMessage to 500 characters', () => {
729
+ const result = sanitizeProviderUpdate({ welcomeMessage: 'W'.repeat(600) });
730
+ expect(result.welcomeMessage.length).toBeLessThanOrEqual(500);
731
+ });
732
+
733
+ it('should strip HTML from welcomeMessage', () => {
734
+ const result = sanitizeProviderUpdate({ welcomeMessage: '<h1>Welcome</h1>' });
735
+ expect(result.welcomeMessage).toBe('Welcome');
736
+ });
737
+
738
+ it('should truncate country to 60 characters', () => {
739
+ const result = sanitizeProviderUpdate({ country: 'C'.repeat(100) });
740
+ expect(result.country.length).toBeLessThanOrEqual(60);
741
+ });
742
+
743
+ it('should truncate locale to 10 characters', () => {
744
+ const result = sanitizeProviderUpdate({ locale: 'en-US-extra-long' });
745
+ expect(result.locale.length).toBeLessThanOrEqual(10);
746
+ });
747
+
748
+ it('should set photoUrl to null when empty', () => {
749
+ const result = sanitizeProviderUpdate({ photoUrl: '' });
750
+ expect(result.photoUrl).toBeNull();
751
+ });
752
+
753
+ it('should accept businessHours as object', () => {
754
+ const hours = { mon: '9-5', tue: '9-5' };
755
+ const result = sanitizeProviderUpdate({ businessHours: hours });
756
+ expect(result.businessHours).toEqual(hours);
757
+ });
758
+
759
+ it('should accept businessHours as null', () => {
760
+ const result = sanitizeProviderUpdate({ businessHours: null });
761
+ expect(result.businessHours).toBeNull();
762
+ });
763
+
764
+ it('should reject businessHours that is a string', () => {
765
+ const result = sanitizeProviderUpdate({ businessHours: '9-5' });
766
+ expect(result.businessHours).toBeUndefined();
767
+ });
768
+
769
+ it('should return empty object when body has no recognized fields', () => {
770
+ const result = sanitizeProviderUpdate({ unknownField: 'anything' });
771
+ expect(Object.keys(result).length).toBe(0);
772
+ });
773
+ });
774
+ });