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,241 @@
1
+ process.env.NODE_ENV = 'test';
2
+
3
+ jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
4
+ jest.mock('stripe', () => jest.fn().mockReturnValue({}));
5
+ jest.mock('openai', () => jest.fn().mockImplementation(() => ({
6
+ chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: 'This is general health information. *This is not medical advice.*' } }] }) } }
7
+ })));
8
+ jest.mock('@google/generative-ai', () => ({
9
+ GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
10
+ getGenerativeModel: () => ({
11
+ generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } })
12
+ })
13
+ }))
14
+ }));
15
+ jest.mock('../../functions/src/stripe-config', () => ({ stripe: {} }));
16
+ jest.mock('../../functions/src/payment', () => ({
17
+ createConnectAccount: jest.fn(),
18
+ createAccountLink: jest.fn(),
19
+ refreshAccountStatus: jest.fn(),
20
+ setProviderPricing: jest.fn(),
21
+ createCheckoutSession: jest.fn(),
22
+ createPaymentIntent: jest.fn(),
23
+ createSubscription: jest.fn(),
24
+ cancelSubscription: jest.fn(),
25
+ checkPaymentAccess: jest.fn(),
26
+ getRevenueAnalytics: jest.fn(),
27
+ handleStripeWebhook: jest.fn()
28
+ }));
29
+
30
+ process.env.DEEPSEEK_API_KEY = 'test-key';
31
+ process.env.GEMINI_API_KEY = 'test-key';
32
+
33
+ const request = require('supertest');
34
+ const admin = require('firebase-admin');
35
+
36
+ admin.auth().verifyIdToken = jest.fn(async (token) => {
37
+ if (token === 'valid-doctor-token') return { uid: 'doctor-uid-1', email: 'doc@test.com' };
38
+ throw new Error('Invalid token');
39
+ });
40
+
41
+ const app = require('../../server');
42
+
43
+ describe('Security & Input Validation', () => {
44
+ beforeEach(() => {
45
+ admin.__reset();
46
+ admin.__seed('providers', 'doctor-uid-1', {
47
+ ownerUid: 'doctor-uid-1',
48
+ handle: 'drsmith',
49
+ displayName: 'Dr. Smith',
50
+ specialty: 'General',
51
+ status: 'published',
52
+ enabledTopics: ['general_health_info'],
53
+ plan: 'free',
54
+ monthlyChatLimit: 50,
55
+ monthlyChatCount: 5,
56
+ contactInfo: null,
57
+ businessHours: null
58
+ });
59
+ });
60
+
61
+ describe('Message length validation', () => {
62
+ it('should reject messages exceeding 5000 characters', async () => {
63
+ const longMessage = 'a'.repeat(5001);
64
+
65
+ const res = await request(app)
66
+ .post('/api/chat')
67
+ .send({
68
+ handle: 'drsmith',
69
+ sessionId: 'test-session-1',
70
+ message: longMessage
71
+ });
72
+
73
+ expect(res.status).toBe(400);
74
+ expect(res.body.error).toMatch(/too long/i);
75
+ });
76
+
77
+ it('should accept messages at exactly 5000 characters', async () => {
78
+ const maxMessage = 'a'.repeat(5000);
79
+
80
+ const res = await request(app)
81
+ .post('/api/chat')
82
+ .send({
83
+ handle: 'drsmith',
84
+ sessionId: 'test-session-2',
85
+ message: maxMessage
86
+ });
87
+
88
+ // Should not be rejected for length (may be 200 or other status but not 400 for length)
89
+ expect(res.status).not.toBe(400);
90
+ });
91
+
92
+ it('should still require handle, sessionId, and message', async () => {
93
+ const res = await request(app)
94
+ .post('/api/chat')
95
+ .send({ handle: 'drsmith' });
96
+
97
+ expect(res.status).toBe(400);
98
+ expect(res.body.error).toMatch(/required/i);
99
+ });
100
+ });
101
+
102
+ describe('JSON body size limit', () => {
103
+ it('should reject excessively large payloads', async () => {
104
+ const hugePayload = { data: 'x'.repeat(200000) };
105
+
106
+ const res = await request(app)
107
+ .post('/api/chat')
108
+ .send(hugePayload);
109
+
110
+ expect(res.status).toBeGreaterThanOrEqual(400);
111
+ });
112
+ });
113
+
114
+ describe('Rate limiting headers', () => {
115
+ it('should include rate limit headers on chat endpoint', async () => {
116
+ const res = await request(app)
117
+ .post('/api/chat')
118
+ .send({
119
+ handle: 'drsmith',
120
+ sessionId: 'test-session-3',
121
+ message: 'Hello'
122
+ });
123
+
124
+ expect(res.headers['x-ratelimit-limit']).toBeDefined();
125
+ expect(res.headers['x-ratelimit-remaining']).toBeDefined();
126
+ });
127
+
128
+ it('should include rate limit headers on search endpoint', async () => {
129
+ const res = await request(app)
130
+ .get('/api/search-providers?q=test');
131
+
132
+ expect(res.headers['x-ratelimit-limit']).toBeDefined();
133
+ expect(res.headers['x-ratelimit-remaining']).toBeDefined();
134
+ });
135
+ });
136
+
137
+ describe('Security headers', () => {
138
+ it('should set security headers on all responses', async () => {
139
+ const res = await request(app).get('/api/launch-status');
140
+
141
+ expect(res.headers['strict-transport-security']).toBeDefined();
142
+ expect(res.headers['x-content-type-options']).toBe('nosniff');
143
+ expect(res.headers['x-frame-options']).toBe('DENY');
144
+ expect(res.headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
145
+ expect(res.headers['x-xss-protection']).toBe('1; mode=block');
146
+ });
147
+ });
148
+
149
+ describe('Input sanitization', () => {
150
+ it('should handle empty message gracefully', async () => {
151
+ const res = await request(app)
152
+ .post('/api/chat')
153
+ .send({
154
+ handle: 'drsmith',
155
+ sessionId: 'test-session-4',
156
+ message: ' '
157
+ });
158
+
159
+ expect(res.status).toBe(400);
160
+ });
161
+
162
+ it('should normalize handle input', async () => {
163
+ const res = await request(app)
164
+ .post('/api/chat')
165
+ .send({
166
+ handle: 'DrSmith',
167
+ sessionId: 'test-session-5',
168
+ message: 'Hello'
169
+ });
170
+
171
+ // Should normalize to lowercase and find provider
172
+ expect(res.status).not.toBe(400);
173
+ });
174
+ });
175
+
176
+ describe('Chat response metadata', () => {
177
+ it('should return topic field in chat response', async () => {
178
+ const res = await request(app)
179
+ .post('/api/chat')
180
+ .send({
181
+ handle: 'drsmith',
182
+ sessionId: 'test-session-topic',
183
+ message: 'What are your clinic hours?'
184
+ });
185
+
186
+ if (res.status === 200) {
187
+ expect(res.body.topic).toBeDefined();
188
+ expect(typeof res.body.topic).toBe('string');
189
+ }
190
+ });
191
+ });
192
+
193
+ describe('404 handling', () => {
194
+ it('should return 404 HTML for invalid handle routes', async () => {
195
+ const res = await request(app)
196
+ .get('/nonexistent-handle')
197
+ .set('Accept', 'text/html');
198
+
199
+ expect(res.status).toBe(404);
200
+ expect(res.headers['content-type']).toMatch(/html/);
201
+ });
202
+
203
+ it('should return 404 JSON for API routes', async () => {
204
+ const res = await request(app)
205
+ .get('/api/nonexistent')
206
+ .set('Accept', 'application/json');
207
+
208
+ expect(res.status).toBe(404);
209
+ });
210
+
211
+ it('should serve chat page for valid handle', async () => {
212
+ // Seed handle mapping
213
+ admin.__seed('handles', 'drsmith', { providerId: 'doctor-uid-1' });
214
+
215
+ const res = await request(app)
216
+ .get('/drsmith')
217
+ .set('Accept', 'text/html');
218
+
219
+ expect(res.status).toBe(200);
220
+ expect(res.headers['content-type']).toMatch(/html/);
221
+ });
222
+ });
223
+
224
+ describe('Health endpoint', () => {
225
+ it('should return basic health info', async () => {
226
+ const res = await request(app).get('/health');
227
+
228
+ expect(res.status).toBe(200);
229
+ expect(res.body.ok).toBe(true);
230
+ expect(res.body.uptime).toBeDefined();
231
+ expect(typeof res.body.memory).toBe('number');
232
+ });
233
+
234
+ it('should support deep health check', async () => {
235
+ const res = await request(app).get('/health?deep=true');
236
+
237
+ expect(res.status).toBe(200);
238
+ expect(res.body.database).toBe('connected');
239
+ });
240
+ });
241
+ });
@@ -0,0 +1,303 @@
1
+ process.env.NODE_ENV = 'test';
2
+
3
+ jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
4
+ jest.mock('stripe', () => jest.fn().mockReturnValue({}));
5
+ jest.mock('openai', () => jest.fn().mockImplementation(() => ({
6
+ chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: '{"response":"Draft reply"}' } }] }) } }
7
+ })));
8
+ jest.mock('@google/generative-ai', () => ({
9
+ GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
10
+ getGenerativeModel: () => ({
11
+ generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } })
12
+ })
13
+ }))
14
+ }));
15
+ jest.mock('../../functions/src/stripe-config', () => ({ stripe: {} }));
16
+ jest.mock('../../functions/src/payment', () => ({
17
+ createConnectAccount: jest.fn().mockResolvedValue({ accountId: 'acct_test', onboardingStatus: 'PENDING' }),
18
+ createAccountLink: jest.fn(),
19
+ refreshAccountStatus: jest.fn().mockResolvedValue({ chargesEnabled: true }),
20
+ setProviderPricing: jest.fn(),
21
+ createCheckoutSession: jest.fn(),
22
+ createPaymentIntent: jest.fn(),
23
+ createSubscription: jest.fn(),
24
+ cancelSubscription: jest.fn(),
25
+ checkPaymentAccess: jest.fn(),
26
+ getRevenueAnalytics: jest.fn(),
27
+ handleStripeWebhook: jest.fn()
28
+ }));
29
+
30
+ process.env.DEEPSEEK_API_KEY = 'test-key';
31
+ process.env.GEMINI_API_KEY = 'test-key';
32
+
33
+ const request = require('supertest');
34
+ const admin = require('firebase-admin');
35
+ const { createConnectAccount, refreshAccountStatus } = require('../../functions/src/payment');
36
+
37
+ admin.auth().verifyIdToken = jest.fn(async (token) => {
38
+ if (token === 'valid-doctor-token') return { uid: 'doctor-uid-1', email: 'doc@test.com' };
39
+ throw new Error('Invalid token');
40
+ });
41
+
42
+ const app = require('../../server');
43
+
44
+ describe('Stripe Connect & Search Providers & Conversation Reply API', () => {
45
+ beforeEach(() => {
46
+ admin.__reset();
47
+ createConnectAccount.mockClear();
48
+ refreshAccountStatus.mockClear();
49
+
50
+ admin.__seed('providers', 'doctor-uid-1', {
51
+ ownerUid: 'doctor-uid-1',
52
+ handle: 'drsmith',
53
+ displayName: 'Dr. Smith',
54
+ specialty: 'General',
55
+ status: 'published',
56
+ plan: 'free'
57
+ });
58
+ });
59
+
60
+ // ──────────────────────────────────────────────────────────────────────
61
+ // POST /api/stripe/create-connect-account
62
+ // ──────────────────────────────────────────────────────────────────────
63
+ describe('POST /api/stripe/create-connect-account', () => {
64
+ it('should return existing account if stripeAccountId already set', async () => {
65
+ admin.__seed('providers', 'doctor-uid-1', {
66
+ ownerUid: 'doctor-uid-1',
67
+ handle: 'drsmith',
68
+ displayName: 'Dr. Smith',
69
+ specialty: 'General',
70
+ status: 'published',
71
+ plan: 'free',
72
+ stripeAccountId: 'acct_existing',
73
+ stripeOnboardingStatus: 'COMPLETE'
74
+ });
75
+
76
+ const res = await request(app)
77
+ .post('/api/stripe/create-connect-account')
78
+ .set('Authorization', 'Bearer valid-doctor-token')
79
+ .send();
80
+
81
+ expect(res.status).toBe(200);
82
+ expect(res.body.accountId).toBe('acct_existing');
83
+ expect(res.body.onboardingStatus).toBe('COMPLETE');
84
+ expect(createConnectAccount).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('should call createConnectAccount when no existing account', async () => {
88
+ const res = await request(app)
89
+ .post('/api/stripe/create-connect-account')
90
+ .set('Authorization', 'Bearer valid-doctor-token')
91
+ .send();
92
+
93
+ expect(res.status).toBe(200);
94
+ expect(res.body.accountId).toBe('acct_test');
95
+ expect(res.body.onboardingStatus).toBe('PENDING');
96
+ expect(createConnectAccount).toHaveBeenCalledWith({
97
+ providerId: 'doctor-uid-1',
98
+ email: 'doc@test.com',
99
+ displayName: 'Dr. Smith',
100
+ country: 'US'
101
+ });
102
+ });
103
+
104
+ it('should reject unauthenticated requests (401)', async () => {
105
+ const res = await request(app)
106
+ .post('/api/stripe/create-connect-account')
107
+ .send();
108
+
109
+ expect(res.status).toBe(401);
110
+ });
111
+
112
+ it('should reject when provider not found (404)', async () => {
113
+ admin.__reset();
114
+
115
+ const res = await request(app)
116
+ .post('/api/stripe/create-connect-account')
117
+ .set('Authorization', 'Bearer valid-doctor-token')
118
+ .send();
119
+
120
+ expect(res.status).toBe(404);
121
+ expect(res.body.error).toMatch(/Provider not found/);
122
+ });
123
+ });
124
+
125
+ // ──────────────────────────────────────────────────────────────────────
126
+ // POST /api/stripe/refresh-account-status
127
+ // ──────────────────────────────────────────────────────────────────────
128
+ describe('POST /api/stripe/refresh-account-status', () => {
129
+ it('should call refreshAccountStatus for valid provider', async () => {
130
+ admin.__seed('providers', 'doctor-uid-1', {
131
+ ownerUid: 'doctor-uid-1',
132
+ handle: 'drsmith',
133
+ displayName: 'Dr. Smith',
134
+ specialty: 'General',
135
+ status: 'published',
136
+ plan: 'free',
137
+ stripeAccountId: 'acct_existing'
138
+ });
139
+
140
+ const res = await request(app)
141
+ .post('/api/stripe/refresh-account-status')
142
+ .set('Authorization', 'Bearer valid-doctor-token')
143
+ .send();
144
+
145
+ expect(res.status).toBe(200);
146
+ expect(res.body.chargesEnabled).toBe(true);
147
+ expect(refreshAccountStatus).toHaveBeenCalledWith({
148
+ providerId: 'doctor-uid-1',
149
+ stripeAccountId: 'acct_existing'
150
+ });
151
+ });
152
+
153
+ it('should reject when no stripeAccountId set (400)', async () => {
154
+ const res = await request(app)
155
+ .post('/api/stripe/refresh-account-status')
156
+ .set('Authorization', 'Bearer valid-doctor-token')
157
+ .send();
158
+
159
+ expect(res.status).toBe(400);
160
+ expect(res.body.error).toMatch(/No Stripe Connect account/);
161
+ });
162
+
163
+ it('should reject unauthenticated (401)', async () => {
164
+ const res = await request(app)
165
+ .post('/api/stripe/refresh-account-status')
166
+ .send();
167
+
168
+ expect(res.status).toBe(401);
169
+ });
170
+ });
171
+
172
+ // ──────────────────────────────────────────────────────────────────────
173
+ // GET /api/search-providers
174
+ // ──────────────────────────────────────────────────────────────────────
175
+ describe('GET /api/search-providers', () => {
176
+ it('should return published providers matching query', async () => {
177
+ admin.__seed('providers', 'provider-a', {
178
+ handle: 'drjones',
179
+ displayName: 'Dr. Jones',
180
+ specialty: 'Cardiology',
181
+ status: 'published'
182
+ });
183
+ admin.__seed('providers', 'provider-b', {
184
+ handle: 'drbrown',
185
+ displayName: 'Dr. Brown',
186
+ specialty: 'Dermatology',
187
+ status: 'published'
188
+ });
189
+ admin.__seed('providers', 'provider-unpub', {
190
+ handle: 'drhidden',
191
+ displayName: 'Dr. Hidden',
192
+ specialty: 'Cardiology',
193
+ status: 'draft'
194
+ });
195
+
196
+ const res = await request(app)
197
+ .get('/api/search-providers')
198
+ .query({ q: 'jones' });
199
+
200
+ expect(res.status).toBe(200);
201
+ expect(res.body.providers).toHaveLength(1);
202
+ expect(res.body.providers[0].displayName).toBe('Dr. Jones');
203
+ });
204
+
205
+ it('should return empty array for no matches', async () => {
206
+ const res = await request(app)
207
+ .get('/api/search-providers')
208
+ .query({ q: 'zzzznonexistent' });
209
+
210
+ expect(res.status).toBe(200);
211
+ expect(res.body.providers).toEqual([]);
212
+ });
213
+
214
+ it('should reject missing query param', async () => {
215
+ const res = await request(app)
216
+ .get('/api/search-providers');
217
+
218
+ expect(res.status).toBe(400);
219
+ expect(res.body.error).toMatch(/Query must be at least 2 characters/);
220
+ });
221
+ });
222
+
223
+ // ──────────────────────────────────────────────────────────────────────
224
+ // POST /api/conversation/:id/reply
225
+ // ──────────────────────────────────────────────────────────────────────
226
+ describe('POST /api/conversation/:id/reply', () => {
227
+ beforeEach(() => {
228
+ admin.__seed('conversations', 'convo-1', {
229
+ providerId: 'doctor-uid-1',
230
+ sessionId: 'session-1',
231
+ lastAssistantMessage: 'Hello'
232
+ });
233
+ });
234
+
235
+ it('should add doctor reply to conversation', async () => {
236
+ const res = await request(app)
237
+ .post('/api/conversation/convo-1/reply')
238
+ .set('Authorization', 'Bearer valid-doctor-token')
239
+ .send({ message: 'Here is my medical advice.' });
240
+
241
+ expect(res.status).toBe(200);
242
+ expect(res.body.success).toBe(true);
243
+
244
+ // Verify message was added to subcollection
245
+ const messages = admin.__getAll('conversations/convo-1/messages');
246
+ const messageKeys = Object.keys(messages);
247
+ expect(messageKeys.length).toBe(1);
248
+ const msg = messages[messageKeys[0]];
249
+ expect(msg.role).toBe('doctor');
250
+ expect(msg.content).toBe('Here is my medical advice.');
251
+ expect(msg.isDoctorManual).toBe(true);
252
+
253
+ // Verify conversation metadata was updated
254
+ const convo = admin.__getAll('conversations')['convo-1'];
255
+ expect(convo.lastAssistantMessage).toBe('Here is my medical advice.');
256
+ });
257
+
258
+ it('should reject empty message', async () => {
259
+ const res = await request(app)
260
+ .post('/api/conversation/convo-1/reply')
261
+ .set('Authorization', 'Bearer valid-doctor-token')
262
+ .send({ message: '' });
263
+
264
+ expect(res.status).toBe(400);
265
+ expect(res.body.error).toMatch(/message is required/);
266
+ });
267
+
268
+ it('should reject message over 10000 chars', async () => {
269
+ const longMessage = 'x'.repeat(10001);
270
+
271
+ const res = await request(app)
272
+ .post('/api/conversation/convo-1/reply')
273
+ .set('Authorization', 'Bearer valid-doctor-token')
274
+ .send({ message: longMessage });
275
+
276
+ expect(res.status).toBe(400);
277
+ expect(res.body.error).toMatch(/exceeds maximum length/);
278
+ });
279
+
280
+ it('should reject access to conversations owned by other providers', async () => {
281
+ admin.__seed('conversations', 'convo-other', {
282
+ providerId: 'other-doctor-uid',
283
+ sessionId: 'session-other'
284
+ });
285
+
286
+ const res = await request(app)
287
+ .post('/api/conversation/convo-other/reply')
288
+ .set('Authorization', 'Bearer valid-doctor-token')
289
+ .send({ message: 'Should not work' });
290
+
291
+ expect(res.status).toBe(404);
292
+ expect(res.body.error).toMatch(/Conversation not found/);
293
+ });
294
+
295
+ it('should reject unauthenticated (401)', async () => {
296
+ const res = await request(app)
297
+ .post('/api/conversation/convo-1/reply')
298
+ .send({ message: 'test' });
299
+
300
+ expect(res.status).toBe(401);
301
+ });
302
+ });
303
+ });