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,328 @@
|
|
|
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: '{}' } }] }) } }
|
|
7
|
+
})));
|
|
8
|
+
jest.mock('@google/generative-ai', () => ({
|
|
9
|
+
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
|
|
10
|
+
getGenerativeModel: () => ({ generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } }) })
|
|
11
|
+
}))
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('../../functions/src/stripe-config', () => ({ stripe: {} }));
|
|
14
|
+
jest.mock('../../functions/src/payment', () => ({
|
|
15
|
+
createConnectAccount: jest.fn(), createAccountLink: jest.fn(), refreshAccountStatus: jest.fn(),
|
|
16
|
+
setProviderPricing: jest.fn(), createCheckoutSession: jest.fn(), createPaymentIntent: jest.fn(),
|
|
17
|
+
createSubscription: jest.fn(), cancelSubscription: jest.fn(), checkPaymentAccess: jest.fn(),
|
|
18
|
+
getRevenueAnalytics: jest.fn(), handleStripeWebhook: jest.fn()
|
|
19
|
+
}));
|
|
20
|
+
process.env.DEEPSEEK_API_KEY = 'test-key';
|
|
21
|
+
process.env.GEMINI_API_KEY = 'test-key';
|
|
22
|
+
|
|
23
|
+
const request = require('supertest');
|
|
24
|
+
const admin = require('firebase-admin');
|
|
25
|
+
|
|
26
|
+
admin.auth().verifyIdToken = jest.fn(async (token) => {
|
|
27
|
+
if (token === 'owner-token') return { uid: 'owner-uid', email: 'owner@clinic.com' };
|
|
28
|
+
if (token === 'invitee-token') return { uid: 'invitee-uid', email: 'newdoc@clinic.com' };
|
|
29
|
+
if (token === 'admin-token') return { uid: 'admin-uid', email: 'admin@clinic.com' };
|
|
30
|
+
if (token === 'clinician-token') return { uid: 'clinician-uid', email: 'clin@clinic.com' };
|
|
31
|
+
throw new Error('Invalid token');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const app = require('../../server');
|
|
35
|
+
|
|
36
|
+
// Seed a clinic org (plan clinic = 5 seats) with owner already a member.
|
|
37
|
+
function seedClinic(plan = 'clinic', seats = 5) {
|
|
38
|
+
admin.__seed('providers', 'owner-uid', {
|
|
39
|
+
ownerUid: 'owner-uid', displayName: 'Dr Owner', handle: 'drowner',
|
|
40
|
+
plan, specialty: 'General', status: 'preview'
|
|
41
|
+
});
|
|
42
|
+
admin.__seed('organizations', 'owner-uid', {
|
|
43
|
+
ownerUid: 'owner-uid', name: 'Test Clinic', type: 'clinic', plan, seats
|
|
44
|
+
});
|
|
45
|
+
admin.__seed('organizations/owner-uid/members', 'owner-uid', {
|
|
46
|
+
uid: 'owner-uid', email: 'owner@clinic.com', role: 'owner', providerId: 'owner-uid', status: 'active'
|
|
47
|
+
});
|
|
48
|
+
admin.__seed('memberships', 'owner-uid', { orgId: 'owner-uid', role: 'owner' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Organizations & Team API', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
admin.__reset();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('GET /api/org', () => {
|
|
57
|
+
it('lazily creates a personal org for an existing provider', async () => {
|
|
58
|
+
admin.__seed('providers', 'owner-uid', {
|
|
59
|
+
ownerUid: 'owner-uid', displayName: 'Dr Solo', handle: 'drsolo', plan: 'free', status: 'preview'
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const res = await request(app).get('/api/org').set('Authorization', 'Bearer owner-token');
|
|
63
|
+
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
expect(res.body.role).toBe('owner');
|
|
66
|
+
expect(res.body.org.type).toBe('personal');
|
|
67
|
+
expect(res.body.seatLimit).toBe(1);
|
|
68
|
+
expect(res.body.activeMembers).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns 404 when user has no provider and no membership', async () => {
|
|
72
|
+
const res = await request(app).get('/api/org').set('Authorization', 'Bearer owner-token');
|
|
73
|
+
expect(res.status).toBe(404);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns the seeded clinic org with correct seat math', async () => {
|
|
77
|
+
seedClinic();
|
|
78
|
+
const res = await request(app).get('/api/org').set('Authorization', 'Bearer owner-token');
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(res.body.org.type).toBe('clinic');
|
|
81
|
+
expect(res.body.seatLimit).toBe(5);
|
|
82
|
+
expect(res.body.seatsRemaining).toBe(4);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects unauthenticated requests', async () => {
|
|
86
|
+
const res = await request(app).get('/api/org');
|
|
87
|
+
expect(res.status).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('POST /api/org/invite', () => {
|
|
92
|
+
it('owner can invite a clinician', async () => {
|
|
93
|
+
seedClinic();
|
|
94
|
+
const res = await request(app).post('/api/org/invite')
|
|
95
|
+
.set('Authorization', 'Bearer owner-token')
|
|
96
|
+
.send({ email: 'newdoc@clinic.com', role: 'clinician' });
|
|
97
|
+
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(res.body.success).toBe(true);
|
|
100
|
+
expect(res.body.invite.status).toBe('pending');
|
|
101
|
+
expect(res.body.invite.token).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects an invalid email', async () => {
|
|
105
|
+
seedClinic();
|
|
106
|
+
const res = await request(app).post('/api/org/invite')
|
|
107
|
+
.set('Authorization', 'Bearer owner-token')
|
|
108
|
+
.send({ email: 'not-an-email', role: 'clinician' });
|
|
109
|
+
expect(res.status).toBe(400);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects assigning the owner role', async () => {
|
|
113
|
+
seedClinic();
|
|
114
|
+
const res = await request(app).post('/api/org/invite')
|
|
115
|
+
.set('Authorization', 'Bearer owner-token')
|
|
116
|
+
.send({ email: 'newdoc@clinic.com', role: 'owner' });
|
|
117
|
+
expect(res.status).toBe(403);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('enforces the seat limit on a free plan', async () => {
|
|
121
|
+
seedClinic('free', 1);
|
|
122
|
+
const res = await request(app).post('/api/org/invite')
|
|
123
|
+
.set('Authorization', 'Bearer owner-token')
|
|
124
|
+
.send({ email: 'newdoc@clinic.com', role: 'clinician' });
|
|
125
|
+
expect(res.status).toBe(402);
|
|
126
|
+
expect(res.body.code).toBe('SEAT_LIMIT_REACHED');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a clinician cannot invite members', async () => {
|
|
130
|
+
seedClinic();
|
|
131
|
+
admin.__seed('organizations/owner-uid/members', 'clinician-uid', {
|
|
132
|
+
uid: 'clinician-uid', email: 'clin@clinic.com', role: 'clinician', status: 'active'
|
|
133
|
+
});
|
|
134
|
+
admin.__seed('memberships', 'clinician-uid', { orgId: 'owner-uid', role: 'clinician' });
|
|
135
|
+
|
|
136
|
+
const res = await request(app).post('/api/org/invite')
|
|
137
|
+
.set('Authorization', 'Bearer clinician-token')
|
|
138
|
+
.send({ email: 'another@clinic.com', role: 'clinician' });
|
|
139
|
+
expect(res.status).toBe(403);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects unauthenticated requests', async () => {
|
|
143
|
+
const res = await request(app).post('/api/org/invite').send({ email: 'x@y.com', role: 'clinician' });
|
|
144
|
+
expect(res.status).toBe(401);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('POST /api/org/invite/accept', () => {
|
|
149
|
+
it('invitee with matching email accepts and becomes a member', async () => {
|
|
150
|
+
seedClinic();
|
|
151
|
+
admin.__seed('invitations', 'tok123', {
|
|
152
|
+
orgId: 'owner-uid', email: 'newdoc@clinic.com', role: 'clinician',
|
|
153
|
+
token: 'tok123', status: 'pending', expiresAt: 9999999999999
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const res = await request(app).post('/api/org/invite/accept')
|
|
157
|
+
.set('Authorization', 'Bearer invitee-token')
|
|
158
|
+
.send({ token: 'tok123' });
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
expect(res.body.role).toBe('clinician');
|
|
161
|
+
|
|
162
|
+
const members = await request(app).get('/api/org/members').set('Authorization', 'Bearer owner-token');
|
|
163
|
+
expect(members.body.members.some(m => m.uid === 'invitee-uid')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects a user whose email does not match the invite', async () => {
|
|
167
|
+
seedClinic();
|
|
168
|
+
admin.__seed('invitations', 'tok123', {
|
|
169
|
+
orgId: 'owner-uid', email: 'someoneelse@clinic.com', role: 'clinician',
|
|
170
|
+
token: 'tok123', status: 'pending', expiresAt: 9999999999999
|
|
171
|
+
});
|
|
172
|
+
const res = await request(app).post('/api/org/invite/accept')
|
|
173
|
+
.set('Authorization', 'Bearer invitee-token')
|
|
174
|
+
.send({ token: 'tok123' });
|
|
175
|
+
expect(res.status).toBe(403);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns 404 for an unknown token', async () => {
|
|
179
|
+
seedClinic();
|
|
180
|
+
const res = await request(app).post('/api/org/invite/accept')
|
|
181
|
+
.set('Authorization', 'Bearer invitee-token')
|
|
182
|
+
.send({ token: 'nope' });
|
|
183
|
+
expect(res.status).toBe(404);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejects an already-accepted invite', async () => {
|
|
187
|
+
seedClinic();
|
|
188
|
+
admin.__seed('invitations', 'tok123', {
|
|
189
|
+
orgId: 'owner-uid', email: 'newdoc@clinic.com', role: 'clinician',
|
|
190
|
+
token: 'tok123', status: 'accepted', expiresAt: 9999999999999
|
|
191
|
+
});
|
|
192
|
+
const res = await request(app).post('/api/org/invite/accept')
|
|
193
|
+
.set('Authorization', 'Bearer invitee-token')
|
|
194
|
+
.send({ token: 'tok123' });
|
|
195
|
+
expect(res.status).toBe(409);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('PATCH /api/org/members/:uid', () => {
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
seedClinic();
|
|
202
|
+
admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
|
|
203
|
+
uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', status: 'active'
|
|
204
|
+
});
|
|
205
|
+
admin.__seed('memberships', 'invitee-uid', { orgId: 'owner-uid', role: 'clinician' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('owner can promote a clinician to admin', async () => {
|
|
209
|
+
const res = await request(app).patch('/api/org/members/invitee-uid')
|
|
210
|
+
.set('Authorization', 'Bearer owner-token')
|
|
211
|
+
.send({ role: 'admin' });
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
expect(res.body.role).toBe('admin');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('owner cannot assign the owner role', async () => {
|
|
217
|
+
const res = await request(app).patch('/api/org/members/invitee-uid')
|
|
218
|
+
.set('Authorization', 'Bearer owner-token')
|
|
219
|
+
.send({ role: 'owner' });
|
|
220
|
+
expect(res.status).toBe(403);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('returns 404 for a non-existent member', async () => {
|
|
224
|
+
const res = await request(app).patch('/api/org/members/ghost-uid')
|
|
225
|
+
.set('Authorization', 'Bearer owner-token')
|
|
226
|
+
.send({ role: 'admin' });
|
|
227
|
+
expect(res.status).toBe(404);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('DELETE /api/org/members/:uid', () => {
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
seedClinic();
|
|
234
|
+
admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
|
|
235
|
+
uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', status: 'active'
|
|
236
|
+
});
|
|
237
|
+
admin.__seed('memberships', 'invitee-uid', { orgId: 'owner-uid', role: 'clinician' });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('owner can remove a clinician', async () => {
|
|
241
|
+
const res = await request(app).delete('/api/org/members/invitee-uid')
|
|
242
|
+
.set('Authorization', 'Bearer owner-token');
|
|
243
|
+
expect(res.status).toBe(200);
|
|
244
|
+
|
|
245
|
+
const members = await request(app).get('/api/org/members').set('Authorization', 'Bearer owner-token');
|
|
246
|
+
expect(members.body.members.some(m => m.uid === 'invitee-uid')).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('cannot remove self', async () => {
|
|
250
|
+
const res = await request(app).delete('/api/org/members/owner-uid')
|
|
251
|
+
.set('Authorization', 'Bearer owner-token');
|
|
252
|
+
expect(res.status).toBe(400);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('returns 404 for a non-existent member', async () => {
|
|
256
|
+
const res = await request(app).delete('/api/org/members/ghost-uid')
|
|
257
|
+
.set('Authorization', 'Bearer owner-token');
|
|
258
|
+
expect(res.status).toBe(404);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('GET /api/org/inbox (clinic-wide)', () => {
|
|
263
|
+
beforeEach(() => {
|
|
264
|
+
seedClinic();
|
|
265
|
+
// second provider/member in the clinic
|
|
266
|
+
admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
|
|
267
|
+
uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', providerId: 'clin-provider', status: 'active'
|
|
268
|
+
});
|
|
269
|
+
admin.__seed('memberships', 'invitee-uid', { orgId: 'owner-uid', role: 'clinician' });
|
|
270
|
+
admin.__seed('threads', 't-owner', { doctorId: 'owner-uid', status: 'active', lastMessageAt: { _seconds: 200 }, lastUserMessage: 'hi' });
|
|
271
|
+
admin.__seed('threads', 't-clin', { doctorId: 'clin-provider', status: 'active', lastMessageAt: { _seconds: 100 }, lastUserMessage: 'hello' });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('owner sees threads across all org providers', async () => {
|
|
275
|
+
const res = await request(app).get('/api/org/inbox').set('Authorization', 'Bearer owner-token');
|
|
276
|
+
expect(res.status).toBe(200);
|
|
277
|
+
const ids = res.body.threads.map(t => t.id);
|
|
278
|
+
expect(ids).toContain('t-owner');
|
|
279
|
+
expect(ids).toContain('t-clin');
|
|
280
|
+
expect(res.body.providerCount).toBe(2);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('a clinician cannot view the clinic-wide inbox', async () => {
|
|
284
|
+
admin.__seed('organizations/owner-uid/members', 'clinician-uid', {
|
|
285
|
+
uid: 'clinician-uid', email: 'clin@clinic.com', role: 'clinician', providerId: 'x', status: 'active'
|
|
286
|
+
});
|
|
287
|
+
admin.__seed('memberships', 'clinician-uid', { orgId: 'owner-uid', role: 'clinician' });
|
|
288
|
+
const res = await request(app).get('/api/org/inbox').set('Authorization', 'Bearer clinician-token');
|
|
289
|
+
expect(res.status).toBe(403);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('rejects unauthenticated', async () => {
|
|
293
|
+
const res = await request(app).get('/api/org/inbox');
|
|
294
|
+
expect(res.status).toBe(401);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('GET /api/org/analytics (clinic-wide)', () => {
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
seedClinic();
|
|
301
|
+
admin.__seed('providers', 'owner-uid', {
|
|
302
|
+
ownerUid: 'owner-uid', displayName: 'Dr Owner', handle: 'drowner', plan: 'clinic',
|
|
303
|
+
status: 'preview', totalChatCount: 30, totalEscalationCount: 3
|
|
304
|
+
});
|
|
305
|
+
admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
|
|
306
|
+
uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', providerId: 'clin-provider', status: 'active'
|
|
307
|
+
});
|
|
308
|
+
admin.__seed('providers', 'clin-provider', {
|
|
309
|
+
ownerUid: 'invitee-uid', displayName: 'Dr Two', handle: 'drtwo', status: 'preview',
|
|
310
|
+
totalChatCount: 70, totalEscalationCount: 7
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('aggregates chats and escalations across providers', async () => {
|
|
315
|
+
const res = await request(app).get('/api/org/analytics').set('Authorization', 'Bearer owner-token');
|
|
316
|
+
expect(res.status).toBe(200);
|
|
317
|
+
expect(res.body.totalChats).toBe(100);
|
|
318
|
+
expect(res.body.totalEscalations).toBe(10);
|
|
319
|
+
expect(res.body.escalationRate).toBe(10);
|
|
320
|
+
expect(res.body.perProvider.length).toBe(2);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('rejects unauthenticated', async () => {
|
|
324
|
+
const res = await request(app).get('/api/org/analytics');
|
|
325
|
+
expect(res.status).toBe(401);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|