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,259 @@
|
|
|
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('Chat, Satisfaction & History 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
|
+
monthlyChatCountMonth: new Date().toISOString().slice(0, 7),
|
|
57
|
+
contactInfo: null,
|
|
58
|
+
businessHours: null
|
|
59
|
+
});
|
|
60
|
+
admin.__seed('handles', 'drsmith', { providerId: 'doctor-uid-1' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('POST /api/satisfaction', () => {
|
|
64
|
+
it('should accept a valid rating from the owning session', async () => {
|
|
65
|
+
admin.__seed('conversations', 'conv-1', { providerId: 'doctor-uid-1', sessionId: 'sess-own' });
|
|
66
|
+
|
|
67
|
+
const res = await request(app)
|
|
68
|
+
.post('/api/satisfaction')
|
|
69
|
+
.send({ conversationId: 'conv-1', sessionId: 'sess-own', rating: 4 });
|
|
70
|
+
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
expect(res.body.ok).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should accept a rating against a thread owned by the session', async () => {
|
|
76
|
+
admin.__seed('threads', 'thread-sat', { doctorId: 'doctor-uid-1', sessionId: 'sess-own' });
|
|
77
|
+
|
|
78
|
+
const res = await request(app)
|
|
79
|
+
.post('/api/satisfaction')
|
|
80
|
+
.send({ conversationId: 'thread-sat', sessionId: 'sess-own', rating: 5 });
|
|
81
|
+
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should reject a rating from a non-owning session (IDOR)', async () => {
|
|
86
|
+
admin.__seed('conversations', 'conv-1', { providerId: 'doctor-uid-1', sessionId: 'sess-own' });
|
|
87
|
+
|
|
88
|
+
const res = await request(app)
|
|
89
|
+
.post('/api/satisfaction')
|
|
90
|
+
.send({ conversationId: 'conv-1', sessionId: 'sess-attacker', rating: 1 });
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(403);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should not create a conversation doc for an unknown id', async () => {
|
|
96
|
+
const res = await request(app)
|
|
97
|
+
.post('/api/satisfaction')
|
|
98
|
+
.send({ conversationId: 'does-not-exist', sessionId: 'sess-x', rating: 3 });
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(404);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should reject missing conversationId', async () => {
|
|
104
|
+
const res = await request(app)
|
|
105
|
+
.post('/api/satisfaction')
|
|
106
|
+
.send({ rating: 5, sessionId: 'sess-own' });
|
|
107
|
+
|
|
108
|
+
expect(res.status).toBe(400);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should reject missing sessionId', async () => {
|
|
112
|
+
const res = await request(app)
|
|
113
|
+
.post('/api/satisfaction')
|
|
114
|
+
.send({ conversationId: 'conv-1', rating: 5 });
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(400);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should reject invalid rating value', async () => {
|
|
120
|
+
const res = await request(app)
|
|
121
|
+
.post('/api/satisfaction')
|
|
122
|
+
.send({ conversationId: 'conv-1', sessionId: 'sess-own', rating: 0 });
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(400);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should reject rating above 5', async () => {
|
|
128
|
+
const res = await request(app)
|
|
129
|
+
.post('/api/satisfaction')
|
|
130
|
+
.send({ conversationId: 'conv-1', sessionId: 'sess-own', rating: 6 });
|
|
131
|
+
|
|
132
|
+
expect(res.status).toBe(400);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('GET /api/chat/history', () => {
|
|
137
|
+
it('should return empty array for new session', async () => {
|
|
138
|
+
const res = await request(app)
|
|
139
|
+
.get('/api/chat/history?handle=drsmith&sessionId=new-session');
|
|
140
|
+
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
expect(res.body.messages).toEqual([]);
|
|
143
|
+
expect(res.body.hasMore).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return messages from existing thread', async () => {
|
|
147
|
+
const threadId = 'thread_anon_sess-123_doctor-uid-1';
|
|
148
|
+
admin.__seed('threads', threadId, {
|
|
149
|
+
doctorId: 'doctor-uid-1',
|
|
150
|
+
sessionId: 'sess-123',
|
|
151
|
+
status: 'active'
|
|
152
|
+
});
|
|
153
|
+
admin.__seed(`threads/${threadId}/messages`, 'msg-1', {
|
|
154
|
+
role: 'user',
|
|
155
|
+
content: 'Hello doctor',
|
|
156
|
+
createdAt: { _seconds: 1000 }
|
|
157
|
+
});
|
|
158
|
+
admin.__seed(`threads/${threadId}/messages`, 'msg-2', {
|
|
159
|
+
role: 'assistant',
|
|
160
|
+
content: 'Hello, how can I help?',
|
|
161
|
+
createdAt: { _seconds: 1001 }
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const res = await request(app)
|
|
165
|
+
.get('/api/chat/history?handle=drsmith&sessionId=sess-123');
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(200);
|
|
168
|
+
expect(res.body.messages.length).toBe(2);
|
|
169
|
+
expect(res.body.messages[0].role).toBe('user');
|
|
170
|
+
expect(res.body.messages[1].role).toBe('assistant');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should reject missing handle or sessionId', async () => {
|
|
174
|
+
const res = await request(app)
|
|
175
|
+
.get('/api/chat/history?handle=drsmith');
|
|
176
|
+
|
|
177
|
+
expect(res.status).toBe(400);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('GET /api/me', () => {
|
|
182
|
+
it('should return current provider profile', async () => {
|
|
183
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
184
|
+
ownerUid: 'doctor-uid-1',
|
|
185
|
+
handle: 'drsmith',
|
|
186
|
+
displayName: 'Dr. Smith',
|
|
187
|
+
specialty: 'General',
|
|
188
|
+
status: 'published',
|
|
189
|
+
enabledTopics: ['general_health_info'],
|
|
190
|
+
plan: 'free',
|
|
191
|
+
monthlyChatLimit: 50,
|
|
192
|
+
monthlyChatCount: 5
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const res = await request(app)
|
|
196
|
+
.get('/api/me')
|
|
197
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
198
|
+
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
expect(res.body.handle).toBe('drsmith');
|
|
201
|
+
expect(res.body.displayName).toBe('Dr. Smith');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should reject unauthenticated requests', async () => {
|
|
205
|
+
const res = await request(app).get('/api/me');
|
|
206
|
+
expect(res.status).toBe(401);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should return 404 for non-existent provider', async () => {
|
|
210
|
+
admin.__reset();
|
|
211
|
+
const res = await request(app)
|
|
212
|
+
.get('/api/me')
|
|
213
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
214
|
+
|
|
215
|
+
expect(res.status).toBe(404);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('POST /api/waitlist', () => {
|
|
220
|
+
it('should register a new waitlist entry', async () => {
|
|
221
|
+
const res = await request(app)
|
|
222
|
+
.post('/api/waitlist')
|
|
223
|
+
.send({ email: 'newdoc@example.com', name: 'Dr. New', specialty: 'Cardiology' });
|
|
224
|
+
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
expect(res.body.status).toBe('registered');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should deduplicate by email', async () => {
|
|
230
|
+
admin.__seed('waitlist', 'existing', {
|
|
231
|
+
email: 'existing@example.com',
|
|
232
|
+
approved: false
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const res = await request(app)
|
|
236
|
+
.post('/api/waitlist')
|
|
237
|
+
.send({ email: 'existing@example.com' });
|
|
238
|
+
|
|
239
|
+
expect(res.status).toBe(200);
|
|
240
|
+
expect(res.body.status).toBe('already_registered');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should reject invalid email', async () => {
|
|
244
|
+
const res = await request(app)
|
|
245
|
+
.post('/api/waitlist')
|
|
246
|
+
.send({ email: 'not-an-email' });
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(400);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should reject missing email', async () => {
|
|
252
|
+
const res = await request(app)
|
|
253
|
+
.post('/api/waitlist')
|
|
254
|
+
.send({ name: 'Dr. Nobody' });
|
|
255
|
+
|
|
256
|
+
expect(res.status).toBe(400);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
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(),
|
|
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('Doctor Draft & Notification Preferences 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
|
+
draftsAccepted: 0,
|
|
57
|
+
draftsEdited: 0,
|
|
58
|
+
draftsRejected: 0
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('POST /api/doctor/draft/accept', () => {
|
|
63
|
+
it('should accept an AI draft and send as doctor message', async () => {
|
|
64
|
+
admin.__seed('threads', 'thread-draft-1', {
|
|
65
|
+
doctorId: 'doctor-uid-1',
|
|
66
|
+
aiDraftResponse: 'Here is the AI suggestion',
|
|
67
|
+
aiDraftStatus: 'pending'
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const res = await request(app)
|
|
71
|
+
.post('/api/doctor/draft/accept')
|
|
72
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
73
|
+
.send({ threadId: 'thread-draft-1' });
|
|
74
|
+
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
expect(res.body.success).toBe(true);
|
|
77
|
+
|
|
78
|
+
const thread = admin.__getAll('threads')['thread-draft-1'];
|
|
79
|
+
expect(thread.aiDraftStatus).toBe('accepted');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should accept with edits and mark as edited', async () => {
|
|
83
|
+
admin.__seed('threads', 'thread-draft-2', {
|
|
84
|
+
doctorId: 'doctor-uid-1',
|
|
85
|
+
aiDraftResponse: 'Original draft',
|
|
86
|
+
aiDraftStatus: 'pending'
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const res = await request(app)
|
|
90
|
+
.post('/api/doctor/draft/accept')
|
|
91
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
92
|
+
.send({ threadId: 'thread-draft-2', editedMessage: 'Edited version' });
|
|
93
|
+
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
|
|
96
|
+
const thread = admin.__getAll('threads')['thread-draft-2'];
|
|
97
|
+
expect(thread.aiDraftStatus).toBe('edited');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should reject when no draft is available', async () => {
|
|
101
|
+
admin.__seed('threads', 'thread-no-draft', {
|
|
102
|
+
doctorId: 'doctor-uid-1'
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const res = await request(app)
|
|
106
|
+
.post('/api/doctor/draft/accept')
|
|
107
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
108
|
+
.send({ threadId: 'thread-no-draft' });
|
|
109
|
+
|
|
110
|
+
expect(res.status).toBe(400);
|
|
111
|
+
expect(res.body.error).toMatch(/No draft/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should reject missing threadId', async () => {
|
|
115
|
+
const res = await request(app)
|
|
116
|
+
.post('/api/doctor/draft/accept')
|
|
117
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
118
|
+
.send({});
|
|
119
|
+
|
|
120
|
+
expect(res.status).toBe(400);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should reject access to other doctor threads', async () => {
|
|
124
|
+
admin.__seed('threads', 'thread-other', {
|
|
125
|
+
doctorId: 'other-doctor',
|
|
126
|
+
aiDraftResponse: 'Draft text',
|
|
127
|
+
aiDraftStatus: 'pending'
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const res = await request(app)
|
|
131
|
+
.post('/api/doctor/draft/accept')
|
|
132
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
133
|
+
.send({ threadId: 'thread-other' });
|
|
134
|
+
|
|
135
|
+
expect(res.status).toBe(404);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should reject unauthenticated requests', async () => {
|
|
139
|
+
const res = await request(app)
|
|
140
|
+
.post('/api/doctor/draft/accept')
|
|
141
|
+
.send({ threadId: 'thread-1' });
|
|
142
|
+
|
|
143
|
+
expect(res.status).toBe(401);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('POST /api/doctor/draft/regenerate', () => {
|
|
148
|
+
it('should regenerate a draft for a thread', async () => {
|
|
149
|
+
admin.__seed('threads', 'thread-regen', {
|
|
150
|
+
doctorId: 'doctor-uid-1',
|
|
151
|
+
aiDraftResponse: 'Old draft',
|
|
152
|
+
aiDraftStatus: 'rejected',
|
|
153
|
+
currentTopic: 'general_health_info'
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const res = await request(app)
|
|
157
|
+
.post('/api/doctor/draft/regenerate')
|
|
158
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
159
|
+
.send({ threadId: 'thread-regen' });
|
|
160
|
+
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should reject missing threadId', async () => {
|
|
165
|
+
const res = await request(app)
|
|
166
|
+
.post('/api/doctor/draft/regenerate')
|
|
167
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
168
|
+
.send({});
|
|
169
|
+
|
|
170
|
+
expect(res.status).toBe(400);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should reject access to other doctor threads', async () => {
|
|
174
|
+
admin.__seed('threads', 'thread-other-regen', {
|
|
175
|
+
doctorId: 'other-doctor'
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const res = await request(app)
|
|
179
|
+
.post('/api/doctor/draft/regenerate')
|
|
180
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
181
|
+
.send({ threadId: 'thread-other-regen' });
|
|
182
|
+
|
|
183
|
+
expect(res.status).toBe(404);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('POST /api/doctor/notifications/preferences', () => {
|
|
188
|
+
it('should update notification preferences', async () => {
|
|
189
|
+
const res = await request(app)
|
|
190
|
+
.post('/api/doctor/notifications/preferences')
|
|
191
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
192
|
+
.send({
|
|
193
|
+
escalationNotify: true,
|
|
194
|
+
unreadNotify: false,
|
|
195
|
+
dailyDigestEnabled: true,
|
|
196
|
+
unreadDelayMinutes: 15
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
expect(res.body.success).toBe(true);
|
|
201
|
+
expect(res.body.preferences.escalationNotify).toBe(true);
|
|
202
|
+
expect(res.body.preferences.unreadNotify).toBe(false);
|
|
203
|
+
expect(res.body.preferences.dailyDigestEnabled).toBe(true);
|
|
204
|
+
expect(res.body.preferences.unreadDelayMinutes).toBe(15);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should clamp unreadDelayMinutes to 1-60 range', async () => {
|
|
208
|
+
const res = await request(app)
|
|
209
|
+
.post('/api/doctor/notifications/preferences')
|
|
210
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
211
|
+
.send({ unreadDelayMinutes: 0 });
|
|
212
|
+
|
|
213
|
+
expect(res.status).toBe(200);
|
|
214
|
+
expect(res.body.preferences.unreadDelayMinutes).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should ignore non-boolean values for boolean fields', async () => {
|
|
218
|
+
const res = await request(app)
|
|
219
|
+
.post('/api/doctor/notifications/preferences')
|
|
220
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
221
|
+
.send({ escalationNotify: 'yes', pushEnabled: 42 });
|
|
222
|
+
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(res.body.preferences.escalationNotify).toBeUndefined();
|
|
225
|
+
expect(res.body.preferences.pushEnabled).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should reject unauthenticated requests', async () => {
|
|
229
|
+
const res = await request(app)
|
|
230
|
+
.post('/api/doctor/notifications/preferences')
|
|
231
|
+
.send({ escalationNotify: true });
|
|
232
|
+
|
|
233
|
+
expect(res.status).toBe(401);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|