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
package/storage.rules
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
rules_version = '2';
|
|
2
|
+
|
|
3
|
+
service firebase.storage {
|
|
4
|
+
match /b/{bucket}/o {
|
|
5
|
+
|
|
6
|
+
// Doctor certificates — users can upload their own, 10 MB max, PDF/images only
|
|
7
|
+
match /certificates/{userId}/{allPaths=**} {
|
|
8
|
+
allow write: if request.auth != null
|
|
9
|
+
&& request.auth.uid == userId
|
|
10
|
+
&& request.resource.size < 10 * 1024 * 1024
|
|
11
|
+
&& request.resource.contentType.matches('application/pdf|image/.*');
|
|
12
|
+
|
|
13
|
+
// Only the uploader can read their own certificates
|
|
14
|
+
allow read: if request.auth != null && request.auth.uid == userId;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Deny everything else by default
|
|
18
|
+
match /{allPaths=**} {
|
|
19
|
+
allow read, write: if false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/test-api.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"handle":"mollit-lorem-consequ","sessionId":"test-001","message":"What are the symptoms of flu?"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Firestore Emulator Tests
|
|
2
|
+
|
|
3
|
+
These tests exercise **real Firestore behavior** (atomic `FieldValue` operations and
|
|
4
|
+
`runTransaction` conflict handling) against the Firebase **Firestore emulator**. They are
|
|
5
|
+
NOT part of the default `jest` run because they require the emulator binary to be installed
|
|
6
|
+
and running.
|
|
7
|
+
|
|
8
|
+
## Why they are excluded from the default run
|
|
9
|
+
|
|
10
|
+
The default test suite (`npm test`) ignores everything under `tests/emulator/` via
|
|
11
|
+
`testPathIgnorePatterns` in `jest.config.js`. This keeps the unit/integration suite fast and
|
|
12
|
+
runnable without external dependencies (no emulator, no network).
|
|
13
|
+
|
|
14
|
+
Files named `*.emulator.test.js` are the emulator suite.
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- Java 11+ (required by the Firestore emulator)
|
|
19
|
+
- Firebase CLI: `npm install -g firebase-tools`
|
|
20
|
+
- Emulator installed: `firebase setup:emulators:firestore`
|
|
21
|
+
|
|
22
|
+
## Running
|
|
23
|
+
|
|
24
|
+
Run the emulator suite through `firebase emulators:exec`, which starts the emulator, runs the
|
|
25
|
+
command, and tears the emulator down afterwards:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
firebase emulators:exec --only firestore \
|
|
29
|
+
"npx jest tests/emulator --testPathIgnorePatterns='' "
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The emulator exposes `FIRESTORE_EMULATOR_HOST` (e.g. `localhost:8080`) to the process it runs,
|
|
33
|
+
which `firebase-admin` picks up automatically so all reads/writes hit the emulator instead of
|
|
34
|
+
production.
|
|
35
|
+
|
|
36
|
+
> The provided test file (`counters.emulator.test.js`) is currently a **skeleton**
|
|
37
|
+
> (`describe.skip(...)`). Remove `.skip` and implement the bodies once you have the emulator
|
|
38
|
+
> available locally / in CI.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Firestore EMULATOR integration tests (SKELETON — intentionally skipped).
|
|
5
|
+
*
|
|
6
|
+
* These tests are designed to run against a REAL Firestore emulator (not mocks),
|
|
7
|
+
* so they validate the actual server-side semantics of atomic field operations and
|
|
8
|
+
* transaction conflict handling that unit tests with mocks cannot guarantee.
|
|
9
|
+
*
|
|
10
|
+
* How to run (see ./README.md for full setup):
|
|
11
|
+
*
|
|
12
|
+
* firebase emulators:exec --only firestore \
|
|
13
|
+
* "npx jest tests/emulator --testPathIgnorePatterns='' "
|
|
14
|
+
*
|
|
15
|
+
* `firebase-admin` automatically routes to the emulator when the
|
|
16
|
+
* FIRESTORE_EMULATOR_HOST env var is set by `firebase emulators:exec`.
|
|
17
|
+
*
|
|
18
|
+
* This suite is wrapped in `describe.skip(...)` so that:
|
|
19
|
+
* - it never runs (and never fails) if someone points jest at this directory
|
|
20
|
+
* without an emulator, and
|
|
21
|
+
* - the default `npm test` run ignores this whole directory anyway via
|
|
22
|
+
* `testPathIgnorePatterns` in jest.config.js.
|
|
23
|
+
*
|
|
24
|
+
* To activate: install/start the emulator, then remove `.skip`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Example wiring (uncomment when implementing against the emulator):
|
|
28
|
+
//
|
|
29
|
+
// const admin = require('firebase-admin');
|
|
30
|
+
// const { FieldValue } = require('firebase-admin/firestore');
|
|
31
|
+
//
|
|
32
|
+
// let db;
|
|
33
|
+
// beforeAll(() => {
|
|
34
|
+
// if (!process.env.FIRESTORE_EMULATOR_HOST) {
|
|
35
|
+
// throw new Error(
|
|
36
|
+
// 'FIRESTORE_EMULATOR_HOST is not set — run this suite via `firebase emulators:exec`.'
|
|
37
|
+
// );
|
|
38
|
+
// }
|
|
39
|
+
// if (!admin.apps.length) {
|
|
40
|
+
// admin.initializeApp({ projectId: 'evaldoc-emulator-test' });
|
|
41
|
+
// }
|
|
42
|
+
// db = admin.firestore();
|
|
43
|
+
// });
|
|
44
|
+
|
|
45
|
+
describe.skip('Firestore emulator: counters and transactions', () => {
|
|
46
|
+
describe('FieldValue.increment on monthlyChatCount', () => {
|
|
47
|
+
it('increments monthlyChatCount atomically against the real emulator', async () => {
|
|
48
|
+
// INTENT:
|
|
49
|
+
// 1. Seed a usage doc with { monthlyChatCount: 0 }.
|
|
50
|
+
// 2. Apply update({ monthlyChatCount: FieldValue.increment(1) }) N times
|
|
51
|
+
// (ideally concurrently) and read the doc back.
|
|
52
|
+
// 3. Expect monthlyChatCount === N — proving increment is atomic on the server,
|
|
53
|
+
// not a read-modify-write race.
|
|
54
|
+
expect(true).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('FieldValue.arrayUnion on uniqueSessions', () => {
|
|
59
|
+
it('dedupes session ids so uniqueSessions contains no duplicates', async () => {
|
|
60
|
+
// INTENT:
|
|
61
|
+
// 1. Seed a doc with { uniqueSessions: [] }.
|
|
62
|
+
// 2. arrayUnion the SAME sessionId multiple times, plus a couple of distinct ids.
|
|
63
|
+
// 3. Read back and expect uniqueSessions to contain each id exactly once
|
|
64
|
+
// (arrayUnion deduplicates on the server).
|
|
65
|
+
expect(true).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('runTransaction handle-uniqueness conflict', () => {
|
|
70
|
+
it('rejects a transaction that would create a duplicate provider handle', async () => {
|
|
71
|
+
// INTENT:
|
|
72
|
+
// 1. Reserve a handle by writing a /handles/{handle} doc inside a transaction
|
|
73
|
+
// that first asserts the doc does not exist.
|
|
74
|
+
// 2. Run a second, concurrent transaction attempting to claim the SAME handle.
|
|
75
|
+
// 3. Expect exactly one transaction to succeed and the other to be REJECTED
|
|
76
|
+
// (contention / already-exists) — proving uniqueness is enforced by the
|
|
77
|
+
// transaction, not by application-level checks alone.
|
|
78
|
+
await expect(Promise.reject(new Error('conflict'))).rejects.toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
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":"x"}' } }] }) } }
|
|
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
|
+
|
|
21
|
+
process.env.DEEPSEEK_API_KEY = 'test-key';
|
|
22
|
+
process.env.GEMINI_API_KEY = 'test-key';
|
|
23
|
+
|
|
24
|
+
const request = require('supertest');
|
|
25
|
+
const admin = require('firebase-admin');
|
|
26
|
+
|
|
27
|
+
admin.auth().verifyIdToken = jest.fn(async (token) => {
|
|
28
|
+
if (token === 'valid-doctor-token') return { uid: 'doctor-uid-1', email: 'doc@test.com' };
|
|
29
|
+
throw new Error('Invalid token');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const app = require('../../server');
|
|
33
|
+
|
|
34
|
+
function seedProvider(overrides = {}) {
|
|
35
|
+
admin.__seed('providers', 'doctor-uid-1', {
|
|
36
|
+
ownerUid: 'doctor-uid-1',
|
|
37
|
+
handle: 'drsmith',
|
|
38
|
+
displayName: 'Dr. Smith',
|
|
39
|
+
specialty: 'General',
|
|
40
|
+
status: 'published',
|
|
41
|
+
enabledTopics: ['general_health_info'],
|
|
42
|
+
plan: 'free',
|
|
43
|
+
monthlyChatLimit: 50,
|
|
44
|
+
monthlyChatCount: 5,
|
|
45
|
+
verificationStatus: 'pending',
|
|
46
|
+
...overrides
|
|
47
|
+
});
|
|
48
|
+
admin.__seed('handles', 'drsmith', { providerId: 'doctor-uid-1' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Account & Self-Serve API', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
admin.__reset();
|
|
54
|
+
seedProvider();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('GET /api/provider/verification-status', () => {
|
|
58
|
+
it('returns pending status with SLA message and canUsePreview=true', async () => {
|
|
59
|
+
seedProvider({ verificationStatus: 'pending' });
|
|
60
|
+
|
|
61
|
+
const res = await request(app)
|
|
62
|
+
.get('/api/provider/verification-status')
|
|
63
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
64
|
+
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
expect(res.body.verificationStatus).toBe('pending');
|
|
67
|
+
expect(res.body.slaHours).toBe(48);
|
|
68
|
+
expect(res.body.message).toMatch(/under review/i);
|
|
69
|
+
expect(res.body.canUsePreview).toBe(true);
|
|
70
|
+
expect(res.body.canResubmit).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns verified status with canUsePreview=false', async () => {
|
|
74
|
+
seedProvider({ verificationStatus: 'verified' });
|
|
75
|
+
|
|
76
|
+
const res = await request(app)
|
|
77
|
+
.get('/api/provider/verification-status')
|
|
78
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
79
|
+
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
expect(res.body.verificationStatus).toBe('verified');
|
|
82
|
+
expect(res.body.canUsePreview).toBe(false);
|
|
83
|
+
expect(res.body.canResubmit).toBe(false);
|
|
84
|
+
expect(res.body.message).toMatch(/verified/i);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns rejected status with canResubmit=true and rejectionReason', async () => {
|
|
88
|
+
seedProvider({
|
|
89
|
+
verificationStatus: 'rejected',
|
|
90
|
+
verificationRejectionReason: 'License could not be verified'
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const res = await request(app)
|
|
94
|
+
.get('/api/provider/verification-status')
|
|
95
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(res.body.verificationStatus).toBe('rejected');
|
|
99
|
+
expect(res.body.canResubmit).toBe(true);
|
|
100
|
+
expect(res.body.rejectionReason).toBe('License could not be verified');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects unauthenticated requests', async () => {
|
|
104
|
+
const res = await request(app).get('/api/provider/verification-status');
|
|
105
|
+
expect(res.status).toBe(401);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('GET /api/provider/onboarding-status', () => {
|
|
110
|
+
it('returns steps, percentComplete, and nextStep for a partially-onboarded provider', async () => {
|
|
111
|
+
seedProvider({
|
|
112
|
+
displayName: 'Dr. Smith',
|
|
113
|
+
specialty: 'General',
|
|
114
|
+
welcomeMessage: '',
|
|
115
|
+
brandPrimaryColor: '',
|
|
116
|
+
certificateUrl: '',
|
|
117
|
+
status: 'draft'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const res = await request(app)
|
|
121
|
+
.get('/api/provider/onboarding-status')
|
|
122
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
expect(Array.isArray(res.body.steps)).toBe(true);
|
|
126
|
+
expect(res.body.steps.length).toBeGreaterThan(0);
|
|
127
|
+
expect(typeof res.body.percentComplete).toBe('number');
|
|
128
|
+
expect(res.body.nextStep).not.toBeNull();
|
|
129
|
+
expect(res.body.isComplete).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns isComplete=true when all required steps are done', async () => {
|
|
133
|
+
seedProvider({
|
|
134
|
+
displayName: 'Dr. Smith',
|
|
135
|
+
specialty: 'General',
|
|
136
|
+
welcomeMessage: 'Welcome!',
|
|
137
|
+
brandPrimaryColor: '#112233',
|
|
138
|
+
enabledTopics: ['general_health_info'],
|
|
139
|
+
certificateUrl: 'https://example.com/cert.pdf',
|
|
140
|
+
// 'published' satisfies both the preview step and the final published
|
|
141
|
+
// required step, which is needed for isComplete to be true.
|
|
142
|
+
status: 'published'
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const res = await request(app)
|
|
146
|
+
.get('/api/provider/onboarding-status')
|
|
147
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
148
|
+
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
expect(res.body.isComplete).toBe(true);
|
|
151
|
+
expect(res.body.percentComplete).toBe(100);
|
|
152
|
+
expect(res.body.nextStep).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns percentComplete as a number between 0 and 100', async () => {
|
|
156
|
+
const res = await request(app)
|
|
157
|
+
.get('/api/provider/onboarding-status')
|
|
158
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
expect(typeof res.body.percentComplete).toBe('number');
|
|
162
|
+
expect(res.body.percentComplete).toBeGreaterThanOrEqual(0);
|
|
163
|
+
expect(res.body.percentComplete).toBeLessThanOrEqual(100);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects unauthenticated requests', async () => {
|
|
167
|
+
const res = await request(app).get('/api/provider/onboarding-status');
|
|
168
|
+
expect(res.status).toBe(401);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('GET /api/account/export', () => {
|
|
173
|
+
it('returns exportData with provider, threads, conversations arrays and counts', async () => {
|
|
174
|
+
admin.__seed('threads', 't1', { doctorId: 'doctor-uid-1', topic: 'general_health_info' });
|
|
175
|
+
admin.__seed('threads', 't2', { doctorId: 'doctor-uid-1', topic: 'general_health_info' });
|
|
176
|
+
admin.__seed('conversations', 'c1', { providerId: 'doctor-uid-1', sessionId: 's1' });
|
|
177
|
+
|
|
178
|
+
const res = await request(app)
|
|
179
|
+
.get('/api/account/export')
|
|
180
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
181
|
+
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
expect(res.body.provider).toBeDefined();
|
|
184
|
+
expect(res.body.provider.id).toBe('doctor-uid-1');
|
|
185
|
+
expect(Array.isArray(res.body.threads)).toBe(true);
|
|
186
|
+
expect(Array.isArray(res.body.conversations)).toBe(true);
|
|
187
|
+
expect(res.body.counts.threads).toBe(2);
|
|
188
|
+
expect(res.body.counts.conversations).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('sets a Content-Disposition attachment header', async () => {
|
|
192
|
+
const res = await request(app)
|
|
193
|
+
.get('/api/account/export')
|
|
194
|
+
.set('Authorization', 'Bearer valid-doctor-token');
|
|
195
|
+
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
expect(res.headers['content-disposition']).toMatch(/attachment/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects unauthenticated requests', async () => {
|
|
201
|
+
const res = await request(app).get('/api/account/export');
|
|
202
|
+
expect(res.status).toBe(401);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('DELETE /api/account', () => {
|
|
207
|
+
it('requires confirm:DELETE in body (400 without it)', async () => {
|
|
208
|
+
const res = await request(app)
|
|
209
|
+
.delete('/api/account')
|
|
210
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
211
|
+
.send({});
|
|
212
|
+
|
|
213
|
+
expect(res.status).toBe(400);
|
|
214
|
+
expect(res.body.error).toMatch(/confirm/i);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('deletes provider, releases handle, and cascades threads/conversations when confirmed', async () => {
|
|
218
|
+
admin.__seed('threads', 't1', { doctorId: 'doctor-uid-1' });
|
|
219
|
+
admin.__seed('threads/t1/messages', 'm1', { text: 'hi' });
|
|
220
|
+
admin.__seed('conversations', 'c1', { providerId: 'doctor-uid-1', sessionId: 's1' });
|
|
221
|
+
|
|
222
|
+
const res = await request(app)
|
|
223
|
+
.delete('/api/account')
|
|
224
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
225
|
+
.send({ confirm: 'DELETE' });
|
|
226
|
+
|
|
227
|
+
expect(res.status).toBe(200);
|
|
228
|
+
expect(res.body.success).toBe(true);
|
|
229
|
+
expect(res.body.deleted.provider).toBe('doctor-uid-1');
|
|
230
|
+
expect(res.body.deleted.threads).toBe(1);
|
|
231
|
+
expect(res.body.deleted.conversations).toBe(1);
|
|
232
|
+
|
|
233
|
+
expect(admin.__getAll('providers')['doctor-uid-1']).toBeUndefined();
|
|
234
|
+
expect(admin.__getAll('handles')['drsmith']).toBeUndefined();
|
|
235
|
+
expect(admin.__getAll('threads')['t1']).toBeUndefined();
|
|
236
|
+
expect(admin.__getAll('conversations')['c1']).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('returns 404 when no provider exists', async () => {
|
|
240
|
+
admin.__reset();
|
|
241
|
+
|
|
242
|
+
const res = await request(app)
|
|
243
|
+
.delete('/api/account')
|
|
244
|
+
.set('Authorization', 'Bearer valid-doctor-token')
|
|
245
|
+
.send({ confirm: 'DELETE' });
|
|
246
|
+
|
|
247
|
+
expect(res.status).toBe(404);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('rejects unauthenticated requests', async () => {
|
|
251
|
+
const res = await request(app)
|
|
252
|
+
.delete('/api/account')
|
|
253
|
+
.send({ confirm: 'DELETE' });
|
|
254
|
+
|
|
255
|
+
expect(res.status).toBe(401);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('GET /api/pricing', () => {
|
|
260
|
+
it('returns the b2b_saas model with all plans and patientPayAddOn', async () => {
|
|
261
|
+
const res = await request(app).get('/api/pricing');
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe(200);
|
|
264
|
+
expect(res.body.model).toBe('b2b_saas');
|
|
265
|
+
expect(Array.isArray(res.body.plans)).toBe(true);
|
|
266
|
+
const keys = res.body.plans.map(p => p.key);
|
|
267
|
+
expect(keys).toEqual(expect.arrayContaining(['free', 'pro', 'clinic', 'enterprise']));
|
|
268
|
+
expect(res.body.patientPayAddOn).toBeDefined();
|
|
269
|
+
expect(res.body.patientPayAddOn.available).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('is public (no auth needed)', async () => {
|
|
273
|
+
const res = await request(app).get('/api/pricing');
|
|
274
|
+
expect(res.status).toBe(200);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('GET /api/pricing/:handle', () => {
|
|
279
|
+
it('returns free:true for a provider with free pricing / payment disabled', async () => {
|
|
280
|
+
seedProvider({ paymentEnabled: false, pricingModel: 'free' });
|
|
281
|
+
|
|
282
|
+
const res = await request(app).get('/api/pricing/drsmith');
|
|
283
|
+
|
|
284
|
+
expect(res.status).toBe(200);
|
|
285
|
+
expect(res.body.free).toBe(true);
|
|
286
|
+
expect(res.body.pricingModel).toBe('free');
|
|
287
|
+
expect(res.body.perChatPrice).toBe(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns pricing details for a paid provider', async () => {
|
|
291
|
+
seedProvider({ paymentEnabled: true, pricingModel: 'per-chat', perChatPrice: 5 });
|
|
292
|
+
|
|
293
|
+
const res = await request(app).get('/api/pricing/drsmith');
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe(200);
|
|
296
|
+
expect(res.body.free).toBe(false);
|
|
297
|
+
expect(res.body.pricingModel).toBe('per-chat');
|
|
298
|
+
expect(res.body.perChatPrice).toBe(5);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('returns 404 for a non-existent handle', async () => {
|
|
302
|
+
const res = await request(app).get('/api/pricing/nobody');
|
|
303
|
+
expect(res.status).toBe(404);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('does not expose pricing for a draft (unpublished) provider', async () => {
|
|
307
|
+
seedProvider({ status: 'draft', paymentEnabled: true, pricingModel: 'per-chat', perChatPrice: 5 });
|
|
308
|
+
const res = await request(app).get('/api/pricing/drsmith');
|
|
309
|
+
expect(res.status).toBe(404);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
process.env.NODE_ENV = 'test';
|
|
2
|
+
process.env.INTERNAL_API_KEY = 'test-internal-key';
|
|
3
|
+
process.env.ADMIN_UIDS = 'admin-uid-1';
|
|
4
|
+
|
|
5
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
6
|
+
jest.mock('stripe', () => jest.fn().mockReturnValue({}));
|
|
7
|
+
jest.mock('openai', () => jest.fn().mockImplementation(() => ({
|
|
8
|
+
chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: '{"response":"Draft reply"}' } }] }) } }
|
|
9
|
+
})));
|
|
10
|
+
jest.mock('@google/generative-ai', () => ({
|
|
11
|
+
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
|
|
12
|
+
getGenerativeModel: () => ({
|
|
13
|
+
generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } })
|
|
14
|
+
})
|
|
15
|
+
}))
|
|
16
|
+
}));
|
|
17
|
+
jest.mock('../../functions/src/stripe-config', () => ({ stripe: {} }));
|
|
18
|
+
jest.mock('../../functions/src/payment', () => ({
|
|
19
|
+
createConnectAccount: jest.fn(),
|
|
20
|
+
createAccountLink: jest.fn(),
|
|
21
|
+
refreshAccountStatus: jest.fn(),
|
|
22
|
+
setProviderPricing: jest.fn(),
|
|
23
|
+
createCheckoutSession: jest.fn(),
|
|
24
|
+
createPaymentIntent: jest.fn(),
|
|
25
|
+
createSubscription: jest.fn(),
|
|
26
|
+
cancelSubscription: jest.fn(),
|
|
27
|
+
checkPaymentAccess: jest.fn(),
|
|
28
|
+
getRevenueAnalytics: jest.fn(),
|
|
29
|
+
handleStripeWebhook: jest.fn()
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
process.env.DEEPSEEK_API_KEY = 'test-key';
|
|
33
|
+
process.env.GEMINI_API_KEY = 'test-key';
|
|
34
|
+
|
|
35
|
+
const request = require('supertest');
|
|
36
|
+
const admin = require('firebase-admin');
|
|
37
|
+
|
|
38
|
+
admin.auth().verifyIdToken = jest.fn(async (token) => {
|
|
39
|
+
if (token === 'valid-admin-token') return { uid: 'admin-uid-1', email: 'admin@test.com' };
|
|
40
|
+
if (token === 'non-admin-token') return { uid: 'random-uid', email: 'doc@test.com' };
|
|
41
|
+
throw new Error('Invalid token');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const app = require('../../server');
|
|
45
|
+
|
|
46
|
+
describe('Admin & Internal API Endpoints', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
admin.__reset();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// -------------------------------------------------------
|
|
52
|
+
// POST /api/internal/daily-digest
|
|
53
|
+
// -------------------------------------------------------
|
|
54
|
+
describe('POST /api/internal/daily-digest', () => {
|
|
55
|
+
it('should return 401 when no API key provided', async () => {
|
|
56
|
+
const res = await request(app)
|
|
57
|
+
.post('/api/internal/daily-digest')
|
|
58
|
+
.send();
|
|
59
|
+
|
|
60
|
+
expect(res.status).toBe(401);
|
|
61
|
+
expect(res.body.error).toBe('Unauthorized');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return 401 when wrong API key provided', async () => {
|
|
65
|
+
const res = await request(app)
|
|
66
|
+
.post('/api/internal/daily-digest')
|
|
67
|
+
.set('X-API-Key', 'wrong-key')
|
|
68
|
+
.send();
|
|
69
|
+
|
|
70
|
+
expect(res.status).toBe(401);
|
|
71
|
+
expect(res.body.error).toBe('Unauthorized');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should succeed with correct API key and no providers', async () => {
|
|
75
|
+
const res = await request(app)
|
|
76
|
+
.post('/api/internal/daily-digest')
|
|
77
|
+
.set('X-API-Key', 'test-internal-key')
|
|
78
|
+
.send();
|
|
79
|
+
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
expect(res.body.success).toBe(true);
|
|
82
|
+
expect(res.body.digests).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should iterate providers and send digest notifications', async () => {
|
|
86
|
+
// Seed published providers
|
|
87
|
+
admin.__seed('providers', 'provider-1', {
|
|
88
|
+
status: 'published',
|
|
89
|
+
displayName: 'Dr. Alpha',
|
|
90
|
+
notificationPreferences: { dailyDigestEnabled: true }
|
|
91
|
+
});
|
|
92
|
+
admin.__seed('providers', 'provider-2', {
|
|
93
|
+
status: 'published',
|
|
94
|
+
displayName: 'Dr. Beta',
|
|
95
|
+
notificationPreferences: { dailyDigestEnabled: true }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Seed threads with recent activity so generateDailyDigest produces results
|
|
99
|
+
const now = new Date();
|
|
100
|
+
admin.__seed('threads', 'thread-p1', {
|
|
101
|
+
doctorId: 'provider-1',
|
|
102
|
+
lastMessageAt: now,
|
|
103
|
+
triageCategory: 'urgent',
|
|
104
|
+
unreadByDoctor: 2
|
|
105
|
+
});
|
|
106
|
+
admin.__seed('threads', 'thread-p2', {
|
|
107
|
+
doctorId: 'provider-2',
|
|
108
|
+
lastMessageAt: now,
|
|
109
|
+
triageCategory: 'needs_review',
|
|
110
|
+
unreadByDoctor: 1
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const res = await request(app)
|
|
114
|
+
.post('/api/internal/daily-digest')
|
|
115
|
+
.set('X-API-Key', 'test-internal-key')
|
|
116
|
+
.send();
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.body.success).toBe(true);
|
|
120
|
+
expect(res.body.digests).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should skip providers with dailyDigestEnabled set to false', async () => {
|
|
124
|
+
admin.__seed('providers', 'provider-no-digest', {
|
|
125
|
+
status: 'published',
|
|
126
|
+
displayName: 'Dr. NoDigest',
|
|
127
|
+
notificationPreferences: { dailyDigestEnabled: false }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const res = await request(app)
|
|
131
|
+
.post('/api/internal/daily-digest')
|
|
132
|
+
.set('X-API-Key', 'test-internal-key')
|
|
133
|
+
.send();
|
|
134
|
+
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
expect(res.body.success).toBe(true);
|
|
137
|
+
expect(res.body.digests).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------
|
|
142
|
+
// GET /api/admin/analytics
|
|
143
|
+
// -------------------------------------------------------
|
|
144
|
+
describe('GET /api/admin/analytics', () => {
|
|
145
|
+
it('should return analytics data for authenticated admin', async () => {
|
|
146
|
+
// Seed some providers
|
|
147
|
+
admin.__seed('providers', 'doc-1', {
|
|
148
|
+
displayName: 'Dr. One',
|
|
149
|
+
email: 'one@test.com',
|
|
150
|
+
handle: 'drone',
|
|
151
|
+
specialty: 'Cardiology',
|
|
152
|
+
status: 'published'
|
|
153
|
+
});
|
|
154
|
+
admin.__seed('providers', 'doc-2', {
|
|
155
|
+
displayName: 'Dr. Two',
|
|
156
|
+
email: 'two@test.com',
|
|
157
|
+
handle: 'drtwo',
|
|
158
|
+
specialty: 'Dermatology',
|
|
159
|
+
status: 'draft'
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Seed conversations
|
|
163
|
+
admin.__seed('conversations', 'conv-1', {
|
|
164
|
+
providerId: 'doc-1',
|
|
165
|
+
sessionId: 'session-a',
|
|
166
|
+
createdAt: new Date()
|
|
167
|
+
});
|
|
168
|
+
admin.__seed('conversations', 'conv-2', {
|
|
169
|
+
providerId: 'doc-1',
|
|
170
|
+
sessionId: 'session-b',
|
|
171
|
+
createdAt: new Date()
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const res = await request(app)
|
|
175
|
+
.get('/api/admin/analytics')
|
|
176
|
+
.set('Authorization', 'Bearer valid-admin-token');
|
|
177
|
+
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
expect(res.body.summary).toBeDefined();
|
|
180
|
+
expect(res.body.summary.totalDoctors).toBe(2);
|
|
181
|
+
expect(res.body.summary.activeDoctors).toBe(1);
|
|
182
|
+
expect(res.body.doctors).toHaveLength(2);
|
|
183
|
+
expect(res.body.period).toBe('all');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should reject unauthenticated requests', async () => {
|
|
187
|
+
const res = await request(app)
|
|
188
|
+
.get('/api/admin/analytics');
|
|
189
|
+
|
|
190
|
+
expect(res.status).toBe(401);
|
|
191
|
+
expect(res.body.error).toBe('Unauthorized');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should reject authenticated non-admin users (403)', async () => {
|
|
195
|
+
const res = await request(app)
|
|
196
|
+
.get('/api/admin/analytics')
|
|
197
|
+
.set('Authorization', 'Bearer non-admin-token');
|
|
198
|
+
|
|
199
|
+
expect(res.status).toBe(403);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|