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,408 @@
1
+ /**
2
+ * Unit tests for the pure input-validation module (functions/src/validate.js).
3
+ */
4
+
5
+ const {
6
+ validate,
7
+ isEmail,
8
+ clampInt,
9
+ isHexColor,
10
+ boundedString,
11
+ } = require('../../functions/src/validate');
12
+
13
+ describe('validate() - string type', () => {
14
+ test('happy path: trims and coerces', () => {
15
+ const r = validate({ name: { type: 'string' } }, { name: ' hi ' });
16
+ expect(r.ok).toBe(true);
17
+ expect(r.value.name).toBe('hi');
18
+ });
19
+
20
+ test('coerces non-string to string', () => {
21
+ const r = validate({ name: { type: 'string' } }, { name: 42 });
22
+ expect(r.ok).toBe(true);
23
+ expect(r.value.name).toBe('42');
24
+ });
25
+
26
+ test('maxLength truncates', () => {
27
+ const r = validate({ s: { type: 'string', maxLength: 3 } }, { s: 'abcdef' });
28
+ expect(r.ok).toBe(true);
29
+ expect(r.value.s).toBe('abc');
30
+ });
31
+
32
+ test('required empty -> error', () => {
33
+ const r = validate({ s: { type: 'string', required: true } }, { s: ' ' });
34
+ expect(r.ok).toBe(false);
35
+ expect(r.errors.s).toMatch(/required/);
36
+ });
37
+
38
+ test('min length failure', () => {
39
+ const r = validate({ s: { type: 'string', min: 5 } }, { s: 'ab' });
40
+ expect(r.ok).toBe(false);
41
+ expect(r.errors.s).toMatch(/at least/);
42
+ });
43
+
44
+ test('max length failure (after truncate not applied without maxLength)', () => {
45
+ const r = validate({ s: { type: 'string', max: 3 } }, { s: 'abcdef' });
46
+ expect(r.ok).toBe(false);
47
+ expect(r.errors.s).toMatch(/at most/);
48
+ });
49
+
50
+ test('min/max within bounds passes', () => {
51
+ const r = validate({ s: { type: 'string', min: 2, max: 5 } }, { s: 'abc' });
52
+ expect(r.ok).toBe(true);
53
+ expect(r.value.s).toBe('abc');
54
+ });
55
+
56
+ test('optional empty string is allowed', () => {
57
+ const r = validate({ s: { type: 'string' } }, { s: '' });
58
+ expect(r.ok).toBe(true);
59
+ expect(r.value.s).toBe('');
60
+ });
61
+ });
62
+
63
+ describe('validate() - email type', () => {
64
+ test('valid email lowercased & trimmed', () => {
65
+ const r = validate({ e: { type: 'email' } }, { e: ' Foo@Bar.COM ' });
66
+ expect(r.ok).toBe(true);
67
+ expect(r.value.e).toBe('foo@bar.com');
68
+ });
69
+
70
+ test('invalid: no @', () => {
71
+ const r = validate({ e: { type: 'email' } }, { e: 'foobar.com' });
72
+ expect(r.ok).toBe(false);
73
+ expect(r.errors.e).toMatch(/valid email/);
74
+ });
75
+
76
+ test('invalid: no domain dot', () => {
77
+ const r = validate({ e: { type: 'email' } }, { e: 'foo@bar' });
78
+ expect(r.ok).toBe(false);
79
+ });
80
+
81
+ test('invalid: spaces', () => {
82
+ const r = validate({ e: { type: 'email' } }, { e: 'fo o@bar.com' });
83
+ expect(r.ok).toBe(false);
84
+ });
85
+
86
+ test('invalid: double @', () => {
87
+ const r = validate({ e: { type: 'email' } }, { e: 'a@b@c.com' });
88
+ expect(r.ok).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('validate() - int type', () => {
93
+ test('happy path', () => {
94
+ const r = validate({ n: { type: 'int' } }, { n: '7' });
95
+ expect(r.ok).toBe(true);
96
+ expect(r.value.n).toBe(7);
97
+ });
98
+
99
+ test('clamps below min to min', () => {
100
+ const r = validate({ n: { type: 'int', min: 1, max: 60 } }, { n: 0 });
101
+ expect(r.ok).toBe(true);
102
+ expect(r.value.n).toBe(1);
103
+ });
104
+
105
+ test('clamps above max to max', () => {
106
+ const r = validate({ n: { type: 'int', min: 1, max: 60 } }, { n: 100 });
107
+ expect(r.ok).toBe(true);
108
+ expect(r.value.n).toBe(60);
109
+ });
110
+
111
+ test('in-range unchanged', () => {
112
+ const r = validate({ n: { type: 'int', min: 1, max: 60 } }, { n: 30 });
113
+ expect(r.value.n).toBe(30);
114
+ });
115
+
116
+ test('non-number -> error when no default', () => {
117
+ const r = validate({ n: { type: 'int' } }, { n: 'abc' });
118
+ expect(r.ok).toBe(false);
119
+ expect(r.errors.n).toMatch(/integer/);
120
+ });
121
+
122
+ test('non-number -> uses default when provided', () => {
123
+ const r = validate({ n: { type: 'int', default: 5 } }, { n: 'abc' });
124
+ expect(r.ok).toBe(true);
125
+ expect(r.value.n).toBe(5);
126
+ });
127
+
128
+ test('parses float to int via parseInt', () => {
129
+ const r = validate({ n: { type: 'int' } }, { n: '12.9' });
130
+ expect(r.value.n).toBe(12);
131
+ });
132
+ });
133
+
134
+ describe('validate() - number type', () => {
135
+ test('happy path float', () => {
136
+ const r = validate({ n: { type: 'number' } }, { n: '3.14' });
137
+ expect(r.ok).toBe(true);
138
+ expect(r.value.n).toBeCloseTo(3.14);
139
+ });
140
+
141
+ test('clamps to min', () => {
142
+ const r = validate({ n: { type: 'number', min: 0, max: 1 } }, { n: -2 });
143
+ expect(r.value.n).toBe(0);
144
+ });
145
+
146
+ test('clamps to max', () => {
147
+ const r = validate({ n: { type: 'number', min: 0, max: 1 } }, { n: 5 });
148
+ expect(r.value.n).toBe(1);
149
+ });
150
+
151
+ test('non-number -> error', () => {
152
+ const r = validate({ n: { type: 'number' } }, { n: 'xyz' });
153
+ expect(r.ok).toBe(false);
154
+ expect(r.errors.n).toMatch(/number/);
155
+ });
156
+
157
+ test('non-number -> default', () => {
158
+ const r = validate({ n: { type: 'number', default: 1.5 } }, { n: 'xyz' });
159
+ expect(r.ok).toBe(true);
160
+ expect(r.value.n).toBe(1.5);
161
+ });
162
+ });
163
+
164
+ describe('validate() - boolean type', () => {
165
+ test('accepts true', () => {
166
+ const r = validate({ b: { type: 'boolean' } }, { b: true });
167
+ expect(r.value.b).toBe(true);
168
+ });
169
+
170
+ test('accepts false', () => {
171
+ const r = validate({ b: { type: 'boolean' } }, { b: false });
172
+ expect(r.value.b).toBe(false);
173
+ });
174
+
175
+ test('non-boolean ignored (omitted) when optional', () => {
176
+ const r = validate({ b: { type: 'boolean' } }, { b: 'true' });
177
+ expect(r.ok).toBe(true);
178
+ expect('b' in r.value).toBe(false);
179
+ });
180
+
181
+ test('non-boolean required -> error', () => {
182
+ const r = validate({ b: { type: 'boolean', required: true } }, { b: 'yes' });
183
+ expect(r.ok).toBe(false);
184
+ expect(r.errors.b).toMatch(/boolean/);
185
+ });
186
+
187
+ test('numeric 1 is not a boolean', () => {
188
+ const r = validate({ b: { type: 'boolean' } }, { b: 1 });
189
+ expect('b' in r.value).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe('validate() - enum type', () => {
194
+ test('valid value', () => {
195
+ const r = validate({ c: { type: 'enum', values: ['a', 'b'] } }, { c: 'a' });
196
+ expect(r.ok).toBe(true);
197
+ expect(r.value.c).toBe('a');
198
+ });
199
+
200
+ test('invalid value -> error', () => {
201
+ const r = validate({ c: { type: 'enum', values: ['a', 'b'] } }, { c: 'z' });
202
+ expect(r.ok).toBe(false);
203
+ expect(r.errors.c).toMatch(/one of/);
204
+ });
205
+
206
+ test('invalid value -> default when provided', () => {
207
+ const r = validate(
208
+ { c: { type: 'enum', values: ['a', 'b'], default: 'a' } },
209
+ { c: 'z' }
210
+ );
211
+ expect(r.ok).toBe(true);
212
+ expect(r.value.c).toBe('a');
213
+ });
214
+
215
+ test('empty values array -> error for any input', () => {
216
+ const r = validate({ c: { type: 'enum', values: [] } }, { c: 'a' });
217
+ expect(r.ok).toBe(false);
218
+ });
219
+ });
220
+
221
+ describe('validate() - hexColor type', () => {
222
+ test('valid lowercase', () => {
223
+ const r = validate({ c: { type: 'hexColor' } }, { c: '#aabbcc' });
224
+ expect(r.ok).toBe(true);
225
+ expect(r.value.c).toBe('#aabbcc');
226
+ });
227
+
228
+ test('valid uppercase', () => {
229
+ const r = validate({ c: { type: 'hexColor' } }, { c: '#FFAA00' });
230
+ expect(r.ok).toBe(true);
231
+ });
232
+
233
+ test('invalid: missing #', () => {
234
+ const r = validate({ c: { type: 'hexColor' } }, { c: 'aabbcc' });
235
+ expect(r.ok).toBe(false);
236
+ });
237
+
238
+ test('invalid: 3-digit short form', () => {
239
+ const r = validate({ c: { type: 'hexColor' } }, { c: '#abc' });
240
+ expect(r.ok).toBe(false);
241
+ });
242
+
243
+ test('invalid: non-hex char', () => {
244
+ const r = validate({ c: { type: 'hexColor' } }, { c: '#gggggg' });
245
+ expect(r.ok).toBe(false);
246
+ });
247
+ });
248
+
249
+ describe('validate() - required / optional / default', () => {
250
+ test('absent + required -> error', () => {
251
+ const r = validate({ x: { type: 'string', required: true } }, {});
252
+ expect(r.ok).toBe(false);
253
+ expect(r.errors.x).toMatch(/required/);
254
+ });
255
+
256
+ test('absent + default -> uses default', () => {
257
+ const r = validate({ x: { type: 'string', default: 'd' } }, {});
258
+ expect(r.ok).toBe(true);
259
+ expect(r.value.x).toBe('d');
260
+ });
261
+
262
+ test('absent + optional + no default -> omitted', () => {
263
+ const r = validate({ x: { type: 'string' } }, {});
264
+ expect(r.ok).toBe(true);
265
+ expect('x' in r.value).toBe(false);
266
+ });
267
+
268
+ test('null treated as absent', () => {
269
+ const r = validate({ x: { type: 'string', default: 'd' } }, { x: null });
270
+ expect(r.value.x).toBe('d');
271
+ });
272
+
273
+ test('undefined treated as absent -> required error', () => {
274
+ const r = validate({ x: { type: 'string', required: true } }, { x: undefined });
275
+ expect(r.ok).toBe(false);
276
+ });
277
+ });
278
+
279
+ describe('validate() - multiple fields mixed', () => {
280
+ test('all valid', () => {
281
+ const r = validate(
282
+ {
283
+ name: { type: 'string', required: true },
284
+ age: { type: 'int', min: 0, max: 120 },
285
+ email: { type: 'email' },
286
+ },
287
+ { name: 'Bob', age: 200, email: 'BOB@X.com' }
288
+ );
289
+ expect(r.ok).toBe(true);
290
+ expect(r.value).toEqual({ name: 'Bob', age: 120, email: 'bob@x.com' });
291
+ });
292
+
293
+ test('mixed ok and errors -> ok:false with correct errors map', () => {
294
+ const r = validate(
295
+ {
296
+ name: { type: 'string', required: true },
297
+ email: { type: 'email' },
298
+ color: { type: 'hexColor' },
299
+ },
300
+ { name: '', email: 'bad', color: '#aabbcc' }
301
+ );
302
+ expect(r.ok).toBe(false);
303
+ expect(r.errors.name).toBeDefined();
304
+ expect(r.errors.email).toBeDefined();
305
+ expect(r.errors.color).toBeUndefined();
306
+ expect(r.value.color).toBe('#aabbcc');
307
+ });
308
+
309
+ test('unknown type produces error', () => {
310
+ const r = validate({ x: { type: 'banana' } }, { x: 1 });
311
+ expect(r.ok).toBe(false);
312
+ expect(r.errors.x).toMatch(/unknown type/);
313
+ });
314
+ });
315
+
316
+ describe('validate() - edge cases', () => {
317
+ test('empty rules -> ok', () => {
318
+ const r = validate({}, { anything: 1 });
319
+ expect(r.ok).toBe(true);
320
+ expect(r.value).toEqual({});
321
+ });
322
+
323
+ test('null input handled gracefully', () => {
324
+ const r = validate({ x: { type: 'string', default: 'd' } }, null);
325
+ expect(r.ok).toBe(true);
326
+ expect(r.value.x).toBe('d');
327
+ });
328
+
329
+ test('null rules -> ok', () => {
330
+ const r = validate(null, { x: 1 });
331
+ expect(r.ok).toBe(true);
332
+ });
333
+ });
334
+
335
+ describe('isEmail()', () => {
336
+ test('valid', () => {
337
+ expect(isEmail('a@b.com')).toBe(true);
338
+ });
339
+ test('valid with mixed case/space', () => {
340
+ expect(isEmail(' A@B.COM ')).toBe(true);
341
+ });
342
+ test('invalid no tld dot', () => {
343
+ expect(isEmail('a@b')).toBe(false);
344
+ });
345
+ test('non-string', () => {
346
+ expect(isEmail(123)).toBe(false);
347
+ });
348
+ test('empty', () => {
349
+ expect(isEmail('')).toBe(false);
350
+ });
351
+ });
352
+
353
+ describe('clampInt()', () => {
354
+ test('within range', () => {
355
+ expect(clampInt(5, 1, 10, 0)).toBe(5);
356
+ });
357
+ test('below min', () => {
358
+ expect(clampInt(-3, 1, 10, 0)).toBe(1);
359
+ });
360
+ test('above max', () => {
361
+ expect(clampInt(99, 1, 10, 0)).toBe(10);
362
+ });
363
+ test('non-number returns fallback', () => {
364
+ expect(clampInt('abc', 1, 10, 7)).toBe(7);
365
+ });
366
+ test('parses numeric string', () => {
367
+ expect(clampInt('4', 1, 10, 0)).toBe(4);
368
+ });
369
+ test('no bounds returns parsed value', () => {
370
+ expect(clampInt('42', undefined, undefined, 0)).toBe(42);
371
+ });
372
+ });
373
+
374
+ describe('isHexColor()', () => {
375
+ test('valid', () => {
376
+ expect(isHexColor('#abcdef')).toBe(true);
377
+ });
378
+ test('valid uppercase', () => {
379
+ expect(isHexColor('#ABCDEF')).toBe(true);
380
+ });
381
+ test('invalid short', () => {
382
+ expect(isHexColor('#abc')).toBe(false);
383
+ });
384
+ test('invalid no hash', () => {
385
+ expect(isHexColor('abcdef')).toBe(false);
386
+ });
387
+ test('non-string', () => {
388
+ expect(isHexColor(null)).toBe(false);
389
+ });
390
+ });
391
+
392
+ describe('boundedString()', () => {
393
+ test('trims', () => {
394
+ expect(boundedString(' hi ', 10)).toBe('hi');
395
+ });
396
+ test('truncates', () => {
397
+ expect(boundedString('abcdef', 3)).toBe('abc');
398
+ });
399
+ test('null -> empty', () => {
400
+ expect(boundedString(null, 10)).toBe('');
401
+ });
402
+ test('coerces number', () => {
403
+ expect(boundedString(123, 10)).toBe('123');
404
+ });
405
+ test('no maxLen leaves length', () => {
406
+ expect(boundedString('abcdef')).toBe('abcdef');
407
+ });
408
+ });