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,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
|
+
});
|