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,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for payment API endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Tests the full HTTP request/response cycle for:
|
|
5
|
+
* - Stripe Connect account creation & onboarding
|
|
6
|
+
* - Pricing configuration
|
|
7
|
+
* - Payment access enforcement on chat endpoints
|
|
8
|
+
* - Checkout session creation
|
|
9
|
+
* - Payment intent creation
|
|
10
|
+
* - Revenue analytics & transactions
|
|
11
|
+
* - Webhook handling
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
process.env.NODE_ENV = 'test';
|
|
15
|
+
|
|
16
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
17
|
+
|
|
18
|
+
// Mock Stripe SDK
|
|
19
|
+
const mockStripe = {
|
|
20
|
+
accounts: {
|
|
21
|
+
create: jest.fn(),
|
|
22
|
+
retrieve: jest.fn()
|
|
23
|
+
},
|
|
24
|
+
accountLinks: {
|
|
25
|
+
create: jest.fn()
|
|
26
|
+
},
|
|
27
|
+
products: {
|
|
28
|
+
create: jest.fn()
|
|
29
|
+
},
|
|
30
|
+
prices: {
|
|
31
|
+
create: jest.fn()
|
|
32
|
+
},
|
|
33
|
+
customers: {
|
|
34
|
+
create: jest.fn()
|
|
35
|
+
},
|
|
36
|
+
checkout: {
|
|
37
|
+
sessions: {
|
|
38
|
+
create: jest.fn()
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
paymentIntents: {
|
|
42
|
+
create: jest.fn()
|
|
43
|
+
},
|
|
44
|
+
subscriptions: {
|
|
45
|
+
create: jest.fn(),
|
|
46
|
+
update: jest.fn()
|
|
47
|
+
},
|
|
48
|
+
webhooks: {
|
|
49
|
+
constructEvent: jest.fn()
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
jest.mock('stripe', () => jest.fn().mockReturnValue(mockStripe));
|
|
54
|
+
jest.mock('../../functions/src/stripe-config', () => ({ stripe: mockStripe }));
|
|
55
|
+
|
|
56
|
+
// Mock LLM clients
|
|
57
|
+
jest.mock('openai', () => jest.fn().mockImplementation(() => ({
|
|
58
|
+
chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: 'mock response' } }] }) } }
|
|
59
|
+
})));
|
|
60
|
+
jest.mock('@google/generative-ai', () => ({
|
|
61
|
+
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
|
|
62
|
+
getGenerativeModel: () => ({
|
|
63
|
+
generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock response' } })
|
|
64
|
+
})
|
|
65
|
+
}))
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
process.env.DEEPSEEK_API_KEY = 'test-key';
|
|
69
|
+
process.env.GEMINI_API_KEY = 'test-key';
|
|
70
|
+
process.env.STRIPE_PUBLISHABLE_KEY = 'pk_test_123';
|
|
71
|
+
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
|
|
72
|
+
|
|
73
|
+
const request = require('supertest');
|
|
74
|
+
const admin = require('firebase-admin');
|
|
75
|
+
|
|
76
|
+
// Setup auth mock
|
|
77
|
+
admin.auth().verifyIdToken = jest.fn(async (token) => {
|
|
78
|
+
if (token === 'doctor-token') return { uid: 'doctor-uid-1', email: 'doc@test.com' };
|
|
79
|
+
if (token === 'patient-token') return { uid: 'patient-uid-1', email: 'patient@test.com' };
|
|
80
|
+
throw new Error('Invalid token');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const app = require('../../server');
|
|
84
|
+
|
|
85
|
+
describe('Payment API Endpoints', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
admin.__reset();
|
|
88
|
+
jest.clearAllMocks();
|
|
89
|
+
|
|
90
|
+
// Seed a published provider
|
|
91
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
92
|
+
ownerUid: 'doctor-uid-1',
|
|
93
|
+
handle: 'drtest',
|
|
94
|
+
displayName: 'Dr. Test',
|
|
95
|
+
status: 'published',
|
|
96
|
+
country: 'US',
|
|
97
|
+
enabledTopics: ['general_health_info'],
|
|
98
|
+
pricingModel: 'free',
|
|
99
|
+
paymentEnabled: false,
|
|
100
|
+
stripeAccountId: null,
|
|
101
|
+
chargesEnabled: false,
|
|
102
|
+
payoutsEnabled: false,
|
|
103
|
+
monthlyChatCount: 0,
|
|
104
|
+
monthlyLimit: 100,
|
|
105
|
+
monthlyChatCountMonth: null
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ============================================
|
|
110
|
+
// STRIPE CONNECT ENDPOINTS
|
|
111
|
+
// ============================================
|
|
112
|
+
|
|
113
|
+
describe('POST /api/stripe/create-connect-account', () => {
|
|
114
|
+
it('should create a Connect account for authenticated provider', async () => {
|
|
115
|
+
mockStripe.accounts.create.mockResolvedValue({
|
|
116
|
+
id: 'acct_test_123',
|
|
117
|
+
charges_enabled: false,
|
|
118
|
+
payouts_enabled: false
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const res = await request(app)
|
|
122
|
+
.post('/api/stripe/create-connect-account')
|
|
123
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
124
|
+
.send();
|
|
125
|
+
|
|
126
|
+
expect(res.status).toBe(200);
|
|
127
|
+
expect(res.body.accountId).toBe('acct_test_123');
|
|
128
|
+
expect(res.body.onboardingStatus).toBe('PENDING');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should return existing account if already created', async () => {
|
|
132
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
133
|
+
ownerUid: 'doctor-uid-1',
|
|
134
|
+
handle: 'drtest',
|
|
135
|
+
stripeAccountId: 'acct_existing',
|
|
136
|
+
stripeOnboardingStatus: 'COMPLETE'
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const res = await request(app)
|
|
140
|
+
.post('/api/stripe/create-connect-account')
|
|
141
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
142
|
+
.send();
|
|
143
|
+
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
expect(res.body.accountId).toBe('acct_existing');
|
|
146
|
+
expect(mockStripe.accounts.create).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should reject unauthenticated requests', async () => {
|
|
150
|
+
const res = await request(app)
|
|
151
|
+
.post('/api/stripe/create-connect-account')
|
|
152
|
+
.send();
|
|
153
|
+
|
|
154
|
+
expect(res.status).toBe(401);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('POST /api/stripe/create-account-link', () => {
|
|
159
|
+
it('should generate onboarding link', async () => {
|
|
160
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
161
|
+
ownerUid: 'doctor-uid-1',
|
|
162
|
+
handle: 'drtest',
|
|
163
|
+
stripeAccountId: 'acct_test_123'
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
mockStripe.accountLinks.create.mockResolvedValue({
|
|
167
|
+
url: 'https://connect.stripe.com/onboarding',
|
|
168
|
+
expires_at: 9999999999
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const res = await request(app)
|
|
172
|
+
.post('/api/stripe/create-account-link')
|
|
173
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
174
|
+
.send();
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
expect(res.body.url).toBe('https://connect.stripe.com/onboarding');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should fail if no Stripe account exists', async () => {
|
|
181
|
+
const res = await request(app)
|
|
182
|
+
.post('/api/stripe/create-account-link')
|
|
183
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
184
|
+
.send();
|
|
185
|
+
|
|
186
|
+
expect(res.status).toBe(400);
|
|
187
|
+
expect(res.body.error).toContain('No Stripe Connect account');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('POST /api/stripe/set-pricing', () => {
|
|
192
|
+
it('should set pricing when Stripe account is ready', async () => {
|
|
193
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
194
|
+
ownerUid: 'doctor-uid-1',
|
|
195
|
+
handle: 'drtest',
|
|
196
|
+
displayName: 'Dr. Test',
|
|
197
|
+
stripeAccountId: 'acct_123',
|
|
198
|
+
chargesEnabled: true
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
mockStripe.products.create.mockResolvedValue({ id: 'prod_123' });
|
|
202
|
+
mockStripe.prices.create.mockResolvedValue({ id: 'price_123' });
|
|
203
|
+
|
|
204
|
+
const res = await request(app)
|
|
205
|
+
.post('/api/stripe/set-pricing')
|
|
206
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
207
|
+
.send({ currency: 'usd', prices: { month: 29.99 } });
|
|
208
|
+
|
|
209
|
+
expect(res.status).toBe(200);
|
|
210
|
+
expect(res.body.productId).toBe('prod_123');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should reject when Stripe account is not set up', async () => {
|
|
214
|
+
const res = await request(app)
|
|
215
|
+
.post('/api/stripe/set-pricing')
|
|
216
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
217
|
+
.send({ prices: { month: 10 } });
|
|
218
|
+
|
|
219
|
+
expect(res.status).toBe(400);
|
|
220
|
+
expect(res.body.error).toContain('Stripe Connect onboarding');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should reject when charges not enabled', async () => {
|
|
224
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
225
|
+
ownerUid: 'doctor-uid-1',
|
|
226
|
+
handle: 'drtest',
|
|
227
|
+
stripeAccountId: 'acct_123',
|
|
228
|
+
chargesEnabled: false
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const res = await request(app)
|
|
232
|
+
.post('/api/stripe/set-pricing')
|
|
233
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
234
|
+
.send({ prices: { month: 10 } });
|
|
235
|
+
|
|
236
|
+
expect(res.status).toBe(400);
|
|
237
|
+
expect(res.body.error).toContain('not yet approved');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ============================================
|
|
242
|
+
// PAYMENT CONFIG & PRICING
|
|
243
|
+
// ============================================
|
|
244
|
+
|
|
245
|
+
describe('GET /api/payment/config/:handle', () => {
|
|
246
|
+
it('should return pricing config for a provider', async () => {
|
|
247
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
248
|
+
handle: 'drtest',
|
|
249
|
+
status: 'published',
|
|
250
|
+
pricingModel: 'subscription',
|
|
251
|
+
subscriptionPrice: 29.99,
|
|
252
|
+
paymentEnabled: true,
|
|
253
|
+
currency: 'usd'
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const res = await request(app)
|
|
257
|
+
.get('/api/payment/config/drtest');
|
|
258
|
+
|
|
259
|
+
expect(res.status).toBe(200);
|
|
260
|
+
expect(res.body.pricingModel).toBe('subscription');
|
|
261
|
+
expect(res.body.subscriptionPrice).toBe(29.99);
|
|
262
|
+
expect(res.body.paymentEnabled).toBe(true);
|
|
263
|
+
expect(res.body.stripePublishableKey).toBe('pk_test_123');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should return 404 for unknown handle', async () => {
|
|
267
|
+
const res = await request(app)
|
|
268
|
+
.get('/api/payment/config/unknown');
|
|
269
|
+
|
|
270
|
+
expect(res.status).toBe(404);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('POST /api/payment/update-pricing', () => {
|
|
275
|
+
it('should update pricing configuration', async () => {
|
|
276
|
+
const res = await request(app)
|
|
277
|
+
.post('/api/payment/update-pricing')
|
|
278
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
279
|
+
.send({
|
|
280
|
+
pricingModel: 'subscription',
|
|
281
|
+
subscriptionPrice: 19.99,
|
|
282
|
+
paymentEnabled: true
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(res.status).toBe(200);
|
|
286
|
+
expect(res.body.ok).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should reject invalid pricing model', async () => {
|
|
290
|
+
const res = await request(app)
|
|
291
|
+
.post('/api/payment/update-pricing')
|
|
292
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
293
|
+
.send({ pricingModel: 'invalid_model' });
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe(400);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should reject out-of-range subscription price', async () => {
|
|
299
|
+
const res = await request(app)
|
|
300
|
+
.post('/api/payment/update-pricing')
|
|
301
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
302
|
+
.send({ subscriptionPrice: 50000 });
|
|
303
|
+
|
|
304
|
+
expect(res.status).toBe(400);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ============================================
|
|
309
|
+
// PAYMENT ACCESS ENFORCEMENT ON CHAT
|
|
310
|
+
// ============================================
|
|
311
|
+
|
|
312
|
+
describe('Payment enforcement on POST /api/chat', () => {
|
|
313
|
+
it('should allow chat when payment is not enabled', async () => {
|
|
314
|
+
const res = await request(app)
|
|
315
|
+
.post('/api/chat')
|
|
316
|
+
.send({
|
|
317
|
+
handle: 'drtest',
|
|
318
|
+
sessionId: 'sess_test',
|
|
319
|
+
message: 'Hello'
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(res.status).toBe(200);
|
|
323
|
+
expect(res.body.response).toBeDefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should return 402 when payment is required and not paid', async () => {
|
|
327
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
328
|
+
ownerUid: 'doctor-uid-1',
|
|
329
|
+
handle: 'drtest',
|
|
330
|
+
displayName: 'Dr. Test',
|
|
331
|
+
status: 'published',
|
|
332
|
+
enabledTopics: ['general_health_info'],
|
|
333
|
+
pricingModel: 'subscription',
|
|
334
|
+
paymentEnabled: true,
|
|
335
|
+
subscriptionPrice: 29.99,
|
|
336
|
+
currency: 'usd',
|
|
337
|
+
monthlyChatCount: 0,
|
|
338
|
+
monthlyLimit: 100,
|
|
339
|
+
monthlyChatCountMonth: null
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const res = await request(app)
|
|
343
|
+
.post('/api/chat')
|
|
344
|
+
.send({
|
|
345
|
+
handle: 'drtest',
|
|
346
|
+
sessionId: 'sess_test',
|
|
347
|
+
message: 'Hello'
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(res.status).toBe(402);
|
|
351
|
+
expect(res.body.paymentRequired).toBe(true);
|
|
352
|
+
expect(res.body.paymentConfig).toBeDefined();
|
|
353
|
+
expect(res.body.paymentConfig.pricingModel).toBe('subscription');
|
|
354
|
+
expect(res.body.paymentConfig.subscriptionPrice).toBe(29.99);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should allow chat when patient has active subscription', async () => {
|
|
358
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
359
|
+
ownerUid: 'doctor-uid-1',
|
|
360
|
+
handle: 'drtest',
|
|
361
|
+
displayName: 'Dr. Test',
|
|
362
|
+
status: 'published',
|
|
363
|
+
enabledTopics: ['general_health_info'],
|
|
364
|
+
pricingModel: 'subscription',
|
|
365
|
+
paymentEnabled: true,
|
|
366
|
+
subscriptionPrice: 29.99,
|
|
367
|
+
monthlyChatCount: 0,
|
|
368
|
+
monthlyLimit: 100,
|
|
369
|
+
monthlyChatCountMonth: null
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Seed an active subscription
|
|
373
|
+
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
374
|
+
|
|
375
|
+
// Patch Timestamp.now to support toMillis
|
|
376
|
+
const origNow = admin.firestore.Timestamp.now;
|
|
377
|
+
admin.firestore.Timestamp.now = () => ({
|
|
378
|
+
_seconds: Math.floor(Date.now() / 1000),
|
|
379
|
+
_nanoseconds: 0,
|
|
380
|
+
toMillis: () => Date.now()
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
admin.__seed('subscriptions', 'sub-1', {
|
|
384
|
+
providerId: 'doctor-uid-1',
|
|
385
|
+
patientId: 'sess_paid',
|
|
386
|
+
status: 'active',
|
|
387
|
+
currentPeriodEnd: {
|
|
388
|
+
_seconds: Math.floor(futureDate.getTime() / 1000),
|
|
389
|
+
toMillis: () => futureDate.getTime()
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const res = await request(app)
|
|
394
|
+
.post('/api/chat')
|
|
395
|
+
.send({
|
|
396
|
+
handle: 'drtest',
|
|
397
|
+
sessionId: 'sess_paid',
|
|
398
|
+
message: 'Hello'
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(res.status).toBe(200);
|
|
402
|
+
expect(res.body.response).toBeDefined();
|
|
403
|
+
|
|
404
|
+
admin.firestore.Timestamp.now = origNow;
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ============================================
|
|
409
|
+
// CHECKOUT & PAYMENT INTENT ENDPOINTS
|
|
410
|
+
// ============================================
|
|
411
|
+
|
|
412
|
+
describe('POST /api/payment/create-checkout-session', () => {
|
|
413
|
+
it('should create checkout session for valid provider', async () => {
|
|
414
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
415
|
+
handle: 'drtest',
|
|
416
|
+
stripeAccountId: 'acct_123',
|
|
417
|
+
chargesEnabled: true,
|
|
418
|
+
stripePriceIds: { month: 'price_123' }
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
mockStripe.customers.create.mockResolvedValue({ id: 'cus_123' });
|
|
422
|
+
mockStripe.checkout.sessions.create.mockResolvedValue({
|
|
423
|
+
id: 'cs_test',
|
|
424
|
+
url: 'https://checkout.stripe.com/cs_test'
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const res = await request(app)
|
|
428
|
+
.post('/api/payment/create-checkout-session')
|
|
429
|
+
.send({
|
|
430
|
+
handle: 'drtest',
|
|
431
|
+
sessionId: 'sess_123',
|
|
432
|
+
interval: 'month'
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(res.status).toBe(200);
|
|
436
|
+
expect(res.body.url).toBe('https://checkout.stripe.com/cs_test');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should reject when provider has no Stripe account', async () => {
|
|
440
|
+
const res = await request(app)
|
|
441
|
+
.post('/api/payment/create-checkout-session')
|
|
442
|
+
.send({
|
|
443
|
+
handle: 'drtest',
|
|
444
|
+
sessionId: 'sess_123',
|
|
445
|
+
interval: 'month'
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(res.status).toBe(400);
|
|
449
|
+
expect(res.body.error).toContain('not set up payments');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should reject missing required fields', async () => {
|
|
453
|
+
const res = await request(app)
|
|
454
|
+
.post('/api/payment/create-checkout-session')
|
|
455
|
+
.send({ handle: 'drtest' });
|
|
456
|
+
|
|
457
|
+
expect(res.status).toBe(400);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('POST /api/payment/create-intent', () => {
|
|
462
|
+
it('should create payment intent with Connect', async () => {
|
|
463
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
464
|
+
handle: 'drtest',
|
|
465
|
+
paymentEnabled: true,
|
|
466
|
+
perChatPrice: 5,
|
|
467
|
+
currency: 'usd',
|
|
468
|
+
stripeAccountId: 'acct_123',
|
|
469
|
+
chargesEnabled: true
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
mockStripe.paymentIntents.create.mockResolvedValue({
|
|
473
|
+
id: 'pi_123',
|
|
474
|
+
client_secret: 'pi_123_secret'
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const res = await request(app)
|
|
478
|
+
.post('/api/payment/create-intent')
|
|
479
|
+
.send({
|
|
480
|
+
handle: 'drtest',
|
|
481
|
+
sessionId: 'sess_123'
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
expect(res.status).toBe(200);
|
|
485
|
+
expect(res.body.clientSecret).toBe('pi_123_secret');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should reject when per-chat payment not enabled', async () => {
|
|
489
|
+
const res = await request(app)
|
|
490
|
+
.post('/api/payment/create-intent')
|
|
491
|
+
.send({
|
|
492
|
+
handle: 'drtest',
|
|
493
|
+
sessionId: 'sess_123'
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(res.status).toBe(400);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('should reject when provider has no Stripe account', async () => {
|
|
500
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
501
|
+
handle: 'drtest',
|
|
502
|
+
paymentEnabled: true,
|
|
503
|
+
perChatPrice: 5
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const res = await request(app)
|
|
507
|
+
.post('/api/payment/create-intent')
|
|
508
|
+
.send({
|
|
509
|
+
handle: 'drtest',
|
|
510
|
+
sessionId: 'sess_123'
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(res.status).toBe(400);
|
|
514
|
+
expect(res.body.error).toContain('not set up payments');
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ============================================
|
|
519
|
+
// ACCESS CHECK ENDPOINT
|
|
520
|
+
// ============================================
|
|
521
|
+
|
|
522
|
+
describe('POST /api/payment/check-access', () => {
|
|
523
|
+
it('should return hasAccess: true for free providers', async () => {
|
|
524
|
+
const res = await request(app)
|
|
525
|
+
.post('/api/payment/check-access')
|
|
526
|
+
.send({
|
|
527
|
+
handle: 'drtest',
|
|
528
|
+
sessionId: 'sess_123'
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
expect(res.status).toBe(200);
|
|
532
|
+
expect(res.body.hasAccess).toBe(true);
|
|
533
|
+
expect(res.body.type).toBe('free');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should return hasAccess: false for paid provider without payment', async () => {
|
|
537
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
538
|
+
handle: 'drtest',
|
|
539
|
+
paymentEnabled: true,
|
|
540
|
+
pricingModel: 'subscription'
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const res = await request(app)
|
|
544
|
+
.post('/api/payment/check-access')
|
|
545
|
+
.send({
|
|
546
|
+
handle: 'drtest',
|
|
547
|
+
sessionId: 'sess_123'
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(res.status).toBe(200);
|
|
551
|
+
expect(res.body.hasAccess).toBe(false);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should reject missing fields', async () => {
|
|
555
|
+
const res = await request(app)
|
|
556
|
+
.post('/api/payment/check-access')
|
|
557
|
+
.send({ handle: 'drtest' });
|
|
558
|
+
|
|
559
|
+
expect(res.status).toBe(400);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ============================================
|
|
564
|
+
// REVENUE & TRANSACTIONS
|
|
565
|
+
// ============================================
|
|
566
|
+
|
|
567
|
+
describe('GET /api/payment/revenue', () => {
|
|
568
|
+
it('should return revenue analytics for authenticated provider', async () => {
|
|
569
|
+
const res = await request(app)
|
|
570
|
+
.get('/api/payment/revenue')
|
|
571
|
+
.set('Authorization', 'Bearer doctor-token');
|
|
572
|
+
|
|
573
|
+
expect(res.status).toBe(200);
|
|
574
|
+
expect(res.body).toHaveProperty('totalRevenue');
|
|
575
|
+
expect(res.body).toHaveProperty('transactionCount');
|
|
576
|
+
expect(res.body).toHaveProperty('activeSubscriptions');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('should reject unauthenticated requests', async () => {
|
|
580
|
+
const res = await request(app)
|
|
581
|
+
.get('/api/payment/revenue');
|
|
582
|
+
|
|
583
|
+
expect(res.status).toBe(401);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe('GET /api/payment/transactions', () => {
|
|
588
|
+
it('should return transaction history', async () => {
|
|
589
|
+
admin.__seed('transactions', 'tx-1', {
|
|
590
|
+
providerId: 'doctor-uid-1',
|
|
591
|
+
amount: 2000,
|
|
592
|
+
status: 'succeeded',
|
|
593
|
+
createdAt: { toDate: () => new Date(), _seconds: Math.floor(Date.now() / 1000) }
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const res = await request(app)
|
|
597
|
+
.get('/api/payment/transactions')
|
|
598
|
+
.set('Authorization', 'Bearer doctor-token');
|
|
599
|
+
|
|
600
|
+
expect(res.status).toBe(200);
|
|
601
|
+
expect(res.body.transactions).toBeDefined();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// ============================================
|
|
606
|
+
// SUBSCRIPTION MANAGEMENT
|
|
607
|
+
// ============================================
|
|
608
|
+
|
|
609
|
+
describe('POST /api/payment/cancel-subscription', () => {
|
|
610
|
+
it('should cancel subscription', async () => {
|
|
611
|
+
mockStripe.subscriptions.update.mockResolvedValue({
|
|
612
|
+
id: 'sub_123',
|
|
613
|
+
status: 'active',
|
|
614
|
+
cancel_at_period_end: true
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
admin.__seed('subscriptions', 'sub-1', {
|
|
618
|
+
stripeSubscriptionId: 'sub_123',
|
|
619
|
+
providerId: 'doctor-uid-1',
|
|
620
|
+
status: 'active'
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const res = await request(app)
|
|
624
|
+
.post('/api/payment/cancel-subscription')
|
|
625
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
626
|
+
.send({ subscriptionId: 'sub_123' });
|
|
627
|
+
|
|
628
|
+
expect(res.status).toBe(200);
|
|
629
|
+
expect(res.body.ok).toBe(true);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('should reject cancelling a subscription owned by another provider (IDOR)', async () => {
|
|
633
|
+
admin.__seed('subscriptions', 'sub-foreign', {
|
|
634
|
+
stripeSubscriptionId: 'sub_foreign',
|
|
635
|
+
providerId: 'someone-else',
|
|
636
|
+
status: 'active'
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const res = await request(app)
|
|
640
|
+
.post('/api/payment/cancel-subscription')
|
|
641
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
642
|
+
.send({ subscriptionId: 'sub_foreign' });
|
|
643
|
+
|
|
644
|
+
expect(res.status).toBe(403);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should reject missing subscriptionId', async () => {
|
|
648
|
+
const res = await request(app)
|
|
649
|
+
.post('/api/payment/cancel-subscription')
|
|
650
|
+
.set('Authorization', 'Bearer doctor-token')
|
|
651
|
+
.send({});
|
|
652
|
+
|
|
653
|
+
expect(res.status).toBe(400);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should reject unauthenticated cancel requests', async () => {
|
|
657
|
+
const res = await request(app)
|
|
658
|
+
.post('/api/payment/cancel-subscription')
|
|
659
|
+
.send({ subscriptionId: 'sub_123' });
|
|
660
|
+
|
|
661
|
+
expect(res.status).toBe(401);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
});
|