evaldoc 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- package/tests/unit/validate.test.js +408 -0
|
@@ -0,0 +1,475 @@
|
|
|
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: 'mock' } }] }) } }
|
|
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('Provider Settings API', () => {
|
|
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('POST /api/provider/update', () => {
|
|
62
|
+
it('should update contact info', async () => {
|
|
63
|
+
const res = await request(app)
|
|
64
|
+
.post('/api/provider/update')
|
|
65
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
66
|
+
.send({
|
|
67
|
+
contactInfo: {
|
|
68
|
+
phone: '+1 555-0100',
|
|
69
|
+
email: 'clinic@example.com',
|
|
70
|
+
address: '123 Main St'
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should update business hours', async () => {
|
|
78
|
+
const res = await request(app)
|
|
79
|
+
.post('/api/provider/update')
|
|
80
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
81
|
+
.send({
|
|
82
|
+
businessHours: {
|
|
83
|
+
open: '09:00',
|
|
84
|
+
close: '17:00',
|
|
85
|
+
summary: 'Mon-Fri 9am-5pm'
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(200);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should update welcome message and status', async () => {
|
|
93
|
+
const res = await request(app)
|
|
94
|
+
.post('/api/provider/update')
|
|
95
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
96
|
+
.send({
|
|
97
|
+
welcomeMessage: 'Welcome to our practice!',
|
|
98
|
+
status: 'draft'
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should reject unauthenticated requests', async () => {
|
|
105
|
+
const res = await request(app)
|
|
106
|
+
.post('/api/provider/update')
|
|
107
|
+
.send({ status: 'published' });
|
|
108
|
+
|
|
109
|
+
expect(res.status).toBe(401);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should block publishing without verification', async () => {
|
|
113
|
+
const res = await request(app)
|
|
114
|
+
.post('/api/provider/update')
|
|
115
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
116
|
+
.send({ status: 'published' });
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(403);
|
|
119
|
+
expect(res.body.code).toBe('VERIFICATION_REQUIRED');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should strip HTML from displayName and welcomeMessage', async () => {
|
|
123
|
+
const res = await request(app)
|
|
124
|
+
.post('/api/provider/update')
|
|
125
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
126
|
+
.send({
|
|
127
|
+
displayName: '<script>alert("xss")</script>Dr. Evil',
|
|
128
|
+
welcomeMessage: '<img onerror=alert(1)>Welcome'
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should silently ignore invalid hex color', async () => {
|
|
135
|
+
const res = await request(app)
|
|
136
|
+
.post('/api/provider/update')
|
|
137
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
138
|
+
.send({
|
|
139
|
+
brandPrimaryColor: 'not-a-color',
|
|
140
|
+
displayName: 'Dr. Smith'
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should sanitize contactInfo email format', async () => {
|
|
147
|
+
const res = await request(app)
|
|
148
|
+
.post('/api/provider/update')
|
|
149
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
150
|
+
.send({
|
|
151
|
+
contactInfo: {
|
|
152
|
+
phone: '+1 555-0100',
|
|
153
|
+
email: 'not-valid-email',
|
|
154
|
+
address: '123 Main St'
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should allow publishing when verified', async () => {
|
|
162
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
163
|
+
ownerUid: 'doctor-uid-1',
|
|
164
|
+
handle: 'drsmith',
|
|
165
|
+
displayName: 'Dr. Smith',
|
|
166
|
+
specialty: 'General',
|
|
167
|
+
status: 'draft',
|
|
168
|
+
enabledTopics: ['general_health_info'],
|
|
169
|
+
plan: 'free',
|
|
170
|
+
monthlyChatLimit: 50,
|
|
171
|
+
monthlyChatCount: 5,
|
|
172
|
+
verificationStatus: 'verified',
|
|
173
|
+
contactInfo: null,
|
|
174
|
+
businessHours: null
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const res = await request(app)
|
|
178
|
+
.post('/api/provider/update')
|
|
179
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
180
|
+
.send({ status: 'published' });
|
|
181
|
+
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('GET /api/analytics', () => {
|
|
187
|
+
it('should return plan and usage data', async () => {
|
|
188
|
+
const res = await request(app)
|
|
189
|
+
.get('/api/analytics')
|
|
190
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
191
|
+
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
expect(res.body.provider).toBeDefined();
|
|
194
|
+
expect(res.body.provider.plan).toBe('free');
|
|
195
|
+
expect(res.body.provider.monthlyChatLimit).toBe(50);
|
|
196
|
+
expect(res.body.provider.monthlyChatCount).toBe(5);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should return satisfaction data', async () => {
|
|
200
|
+
const res = await request(app)
|
|
201
|
+
.get('/api/analytics')
|
|
202
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
203
|
+
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
expect(res.body.satisfaction).toBeDefined();
|
|
206
|
+
expect(res.body.satisfaction).toHaveProperty('average');
|
|
207
|
+
expect(res.body.satisfaction).toHaveProperty('count');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should return topTopics and topQuestions', async () => {
|
|
211
|
+
const res = await request(app)
|
|
212
|
+
.get('/api/analytics')
|
|
213
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
214
|
+
|
|
215
|
+
expect(res.status).toBe(200);
|
|
216
|
+
expect(res.body.topTopics).toBeDefined();
|
|
217
|
+
expect(Array.isArray(res.body.topTopics)).toBe(true);
|
|
218
|
+
expect(res.body.topQuestions).toBeDefined();
|
|
219
|
+
expect(Array.isArray(res.body.topQuestions)).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should return totalConversations count', async () => {
|
|
223
|
+
const res = await request(app)
|
|
224
|
+
.get('/api/analytics')
|
|
225
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
226
|
+
|
|
227
|
+
expect(res.status).toBe(200);
|
|
228
|
+
expect(res.body).toHaveProperty('totalConversations');
|
|
229
|
+
expect(typeof res.body.totalConversations).toBe('number');
|
|
230
|
+
expect(res.body).toHaveProperty('totalChatCount');
|
|
231
|
+
expect(res.body).toHaveProperty('totalEscalations');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should return uniquePatients count', async () => {
|
|
235
|
+
const res = await request(app)
|
|
236
|
+
.get('/api/analytics')
|
|
237
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
238
|
+
|
|
239
|
+
expect(res.status).toBe(200);
|
|
240
|
+
expect(res.body).toHaveProperty('uniquePatients');
|
|
241
|
+
expect(typeof res.body.uniquePatients).toBe('number');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should return escalationRate and resolutionRate', async () => {
|
|
245
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
246
|
+
ownerUid: 'doctor-uid-1',
|
|
247
|
+
handle: 'drsmith',
|
|
248
|
+
displayName: 'Dr. Smith',
|
|
249
|
+
specialty: 'General',
|
|
250
|
+
status: 'published',
|
|
251
|
+
enabledTopics: ['general_health_info'],
|
|
252
|
+
plan: 'free',
|
|
253
|
+
monthlyChatLimit: 50,
|
|
254
|
+
monthlyChatCount: 5,
|
|
255
|
+
draftsAccepted: 8,
|
|
256
|
+
draftsEdited: 2,
|
|
257
|
+
draftsRejected: 0,
|
|
258
|
+
contactInfo: null,
|
|
259
|
+
businessHours: null
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const res = await request(app)
|
|
263
|
+
.get('/api/analytics')
|
|
264
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
265
|
+
|
|
266
|
+
expect(res.status).toBe(200);
|
|
267
|
+
expect(res.body).toHaveProperty('escalationRate');
|
|
268
|
+
expect(typeof res.body.escalationRate).toBe('number');
|
|
269
|
+
expect(res.body).toHaveProperty('resolutionRate');
|
|
270
|
+
expect(res.body.resolutionRate).toBe(100);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should return draftMetrics in analytics', async () => {
|
|
274
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
275
|
+
ownerUid: 'doctor-uid-1',
|
|
276
|
+
handle: 'drsmith',
|
|
277
|
+
displayName: 'Dr. Smith',
|
|
278
|
+
specialty: 'General',
|
|
279
|
+
status: 'published',
|
|
280
|
+
enabledTopics: ['general_health_info'],
|
|
281
|
+
plan: 'free',
|
|
282
|
+
monthlyChatLimit: 50,
|
|
283
|
+
monthlyChatCount: 5,
|
|
284
|
+
draftsAccepted: 10,
|
|
285
|
+
draftsEdited: 3,
|
|
286
|
+
draftsRejected: 2,
|
|
287
|
+
contactInfo: null,
|
|
288
|
+
businessHours: null
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const res = await request(app)
|
|
292
|
+
.get('/api/analytics')
|
|
293
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe(200);
|
|
296
|
+
expect(res.body.draftMetrics).toBeDefined();
|
|
297
|
+
expect(res.body.draftMetrics.accepted).toBe(10);
|
|
298
|
+
expect(res.body.draftMetrics.edited).toBe(3);
|
|
299
|
+
expect(res.body.draftMetrics.rejected).toBe(2);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should reject unauthenticated requests', async () => {
|
|
303
|
+
const res = await request(app)
|
|
304
|
+
.get('/api/analytics');
|
|
305
|
+
|
|
306
|
+
expect(res.status).toBe(401);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('GET /api/conversations', () => {
|
|
311
|
+
it('should return hasMore pagination metadata', async () => {
|
|
312
|
+
const res = await request(app)
|
|
313
|
+
.get('/api/conversations')
|
|
314
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
315
|
+
|
|
316
|
+
expect(res.status).toBe(200);
|
|
317
|
+
expect(res.body).toHaveProperty('hasMore');
|
|
318
|
+
expect(res.body).toHaveProperty('conversations');
|
|
319
|
+
expect(Array.isArray(res.body.conversations)).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should filter by satisfaction=high', async () => {
|
|
323
|
+
admin.__seed('conversations', 'conv-high', {
|
|
324
|
+
providerId: 'doctor-uid-1',
|
|
325
|
+
satisfactionRating: 5,
|
|
326
|
+
topic: 'general',
|
|
327
|
+
updatedAt: new Date()
|
|
328
|
+
});
|
|
329
|
+
admin.__seed('conversations', 'conv-low', {
|
|
330
|
+
providerId: 'doctor-uid-1',
|
|
331
|
+
satisfactionRating: 1,
|
|
332
|
+
topic: 'general',
|
|
333
|
+
updatedAt: new Date()
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const res = await request(app)
|
|
337
|
+
.get('/api/conversations?satisfaction=high')
|
|
338
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
339
|
+
|
|
340
|
+
expect(res.status).toBe(200);
|
|
341
|
+
const ratings = res.body.conversations.map(c => c.satisfactionRating);
|
|
342
|
+
expect(ratings.every(r => r >= 4)).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should filter by satisfaction=none for unrated', async () => {
|
|
346
|
+
admin.__seed('conversations', 'conv-unrated', {
|
|
347
|
+
providerId: 'doctor-uid-1',
|
|
348
|
+
topic: 'general',
|
|
349
|
+
updatedAt: new Date()
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const res = await request(app)
|
|
353
|
+
.get('/api/conversations?satisfaction=none')
|
|
354
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
355
|
+
|
|
356
|
+
expect(res.status).toBe(200);
|
|
357
|
+
const ratings = res.body.conversations.map(c => c.satisfactionRating);
|
|
358
|
+
expect(ratings.every(r => !r)).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('GET /api/safety-events', () => {
|
|
363
|
+
it('should return safety events for provider', async () => {
|
|
364
|
+
admin.__seed('safetyEvents', 'ev-1', {
|
|
365
|
+
providerId: 'doctor-uid-1',
|
|
366
|
+
eventType: 'blocked_input',
|
|
367
|
+
details: { reason: 'PII detected', pattern: 'email' },
|
|
368
|
+
createdAt: { _seconds: 1719100000 }
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const res = await request(app)
|
|
372
|
+
.get('/api/safety-events')
|
|
373
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
374
|
+
|
|
375
|
+
expect(res.status).toBe(200);
|
|
376
|
+
expect(res.body.events).toBeDefined();
|
|
377
|
+
expect(Array.isArray(res.body.events)).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should return empty array when no events', async () => {
|
|
381
|
+
const res = await request(app)
|
|
382
|
+
.get('/api/safety-events')
|
|
383
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
384
|
+
|
|
385
|
+
expect(res.status).toBe(200);
|
|
386
|
+
expect(res.body.events).toBeDefined();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should reject unauthenticated requests', async () => {
|
|
390
|
+
const res = await request(app)
|
|
391
|
+
.get('/api/safety-events');
|
|
392
|
+
|
|
393
|
+
expect(res.status).toBe(401);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('DELETE /api/conversations/:threadId', () => {
|
|
398
|
+
it('should delete a conversation owned by the provider', async () => {
|
|
399
|
+
admin.__seed('threads', 'thread_test_1', {
|
|
400
|
+
doctorId: 'doctor-uid-1',
|
|
401
|
+
patientId: 'anon_sess1',
|
|
402
|
+
createdAt: new Date()
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const res = await request(app)
|
|
406
|
+
.delete('/api/conversations/thread_test_1')
|
|
407
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
408
|
+
|
|
409
|
+
expect(res.status).toBe(200);
|
|
410
|
+
expect(res.body.success).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should reject deletion of another providers conversation', async () => {
|
|
414
|
+
admin.__seed('threads', 'thread_other', {
|
|
415
|
+
doctorId: 'other-doctor-uid',
|
|
416
|
+
patientId: 'anon_sess2',
|
|
417
|
+
createdAt: new Date()
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const res = await request(app)
|
|
421
|
+
.delete('/api/conversations/thread_other')
|
|
422
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
423
|
+
|
|
424
|
+
expect(res.status).toBe(403);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should return 404 for nonexistent conversation', async () => {
|
|
428
|
+
const res = await request(app)
|
|
429
|
+
.delete('/api/conversations/nonexistent_thread')
|
|
430
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
431
|
+
|
|
432
|
+
expect(res.status).toBe(404);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should reject unauthenticated requests', async () => {
|
|
436
|
+
const res = await request(app)
|
|
437
|
+
.delete('/api/conversations/thread_test_1');
|
|
438
|
+
|
|
439
|
+
expect(res.status).toBe(401);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('POST /api/provider (creation validation)', () => {
|
|
444
|
+
it('should reject creation without displayName', async () => {
|
|
445
|
+
// Remove existing provider so creation is allowed
|
|
446
|
+
admin.__reset();
|
|
447
|
+
|
|
448
|
+
const res = await request(app)
|
|
449
|
+
.post('/api/provider')
|
|
450
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
451
|
+
.send({
|
|
452
|
+
handle: 'newdoc',
|
|
453
|
+
specialty: 'Cardiology'
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(res.status).toBe(400);
|
|
457
|
+
expect(res.body.error).toMatch(/display name/i);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should reject creation without specialty', async () => {
|
|
461
|
+
admin.__reset();
|
|
462
|
+
|
|
463
|
+
const res = await request(app)
|
|
464
|
+
.post('/api/provider')
|
|
465
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
466
|
+
.send({
|
|
467
|
+
handle: 'newdoc',
|
|
468
|
+
displayName: 'Dr. New'
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(res.status).toBe(400);
|
|
472
|
+
expect(res.body.error).toMatch(/specialty/i);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
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: 'mock' } }] }) } }
|
|
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
|
+
const app = require('../../server');
|
|
37
|
+
|
|
38
|
+
describe('GET /api/qr/:handle', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
admin.__reset();
|
|
41
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
42
|
+
ownerUid: 'doctor-uid-1',
|
|
43
|
+
handle: 'drsmith',
|
|
44
|
+
displayName: 'Dr. Smith',
|
|
45
|
+
specialty: 'General',
|
|
46
|
+
status: 'published',
|
|
47
|
+
enabledTopics: ['general_health_info'],
|
|
48
|
+
plan: 'free',
|
|
49
|
+
monthlyChatLimit: 50,
|
|
50
|
+
monthlyChatCount: 5,
|
|
51
|
+
contactInfo: null,
|
|
52
|
+
businessHours: null
|
|
53
|
+
});
|
|
54
|
+
admin.__seed('handles', 'drsmith', { providerId: 'doctor-uid-1' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return a PNG QR code for a published provider', async () => {
|
|
58
|
+
const res = await request(app).get('/api/qr/drsmith');
|
|
59
|
+
|
|
60
|
+
expect(res.status).toBe(200);
|
|
61
|
+
expect(res.headers['content-type']).toMatch(/image\/png/);
|
|
62
|
+
expect(res.headers['cache-control']).toMatch(/public/);
|
|
63
|
+
// PNG files start with the PNG magic bytes (0x89504E47)
|
|
64
|
+
expect(res.body[0]).toBe(0x89);
|
|
65
|
+
expect(res.body[1]).toBe(0x50);
|
|
66
|
+
expect(res.body[2]).toBe(0x4e);
|
|
67
|
+
expect(res.body[3]).toBe(0x47);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return 404 for a non-existent provider', async () => {
|
|
71
|
+
const res = await request(app).get('/api/qr/nonexistent');
|
|
72
|
+
|
|
73
|
+
expect(res.status).toBe(404);
|
|
74
|
+
expect(res.body.error).toBe('Provider not found');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return 403 for an unpublished provider', async () => {
|
|
78
|
+
admin.__seed('providers', 'doctor-uid-2', {
|
|
79
|
+
ownerUid: 'doctor-uid-2',
|
|
80
|
+
handle: 'drunpublished',
|
|
81
|
+
displayName: 'Dr. Unpublished',
|
|
82
|
+
specialty: 'General',
|
|
83
|
+
status: 'draft',
|
|
84
|
+
enabledTopics: ['general_health_info'],
|
|
85
|
+
plan: 'free',
|
|
86
|
+
contactInfo: null,
|
|
87
|
+
businessHours: null
|
|
88
|
+
});
|
|
89
|
+
admin.__seed('handles', 'drunpublished', { providerId: 'doctor-uid-2' });
|
|
90
|
+
|
|
91
|
+
const res = await request(app).get('/api/qr/drunpublished');
|
|
92
|
+
|
|
93
|
+
expect(res.status).toBe(403);
|
|
94
|
+
expect(res.body.error).toBe('Provider is not available');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return 404 for a non-existent handle', async () => {
|
|
98
|
+
const res = await request(app).get('/api/qr/nonexistenthandle');
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(404);
|
|
101
|
+
expect(res.body.error).toBe('Provider not found');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should not require authentication', async () => {
|
|
105
|
+
// No Authorization header needed - should still succeed
|
|
106
|
+
const res = await request(app)
|
|
107
|
+
.get('/api/qr/drsmith')
|
|
108
|
+
.set('Authorization', ''); // explicitly no auth
|
|
109
|
+
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
expect(res.headers['content-type']).toMatch(/image\/png/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return a valid PNG with non-zero size', async () => {
|
|
115
|
+
const res = await request(app).get('/api/qr/drsmith');
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
// QR code PNG should have a reasonable size (at least a few hundred bytes)
|
|
119
|
+
expect(res.body.length).toBeGreaterThan(100);
|
|
120
|
+
});
|
|
121
|
+
});
|